From d672883a4c93eabe1d1f8f112fd913274d40986a Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:36:36 -0400 Subject: [PATCH 01/21] feat: improve terminal media playback and navigation Add Kitty/Sixel terminal video, a renderer chooser, native high-resolution Kitty posters, and mouse navigation and playback controls. Preserve tmux pane layouts and correctly detect direct terminals with inherited tmux variables. Support artist/album/track navigation and resolve music containers to playable tracks. Use named mpv loadfile arguments for compatibility with older mpv releases and report command failures. Validated with race-enabled integration tests, go vet, cross-platform builds, and live Kitty/tmux QA for graphics, mouse controls, and music. --- README.md | 31 ++ cmd/play.go | 34 +- cmd/root.go | 10 + cmd/tui.go | 18 +- docs/content/cli/plexctl.md | 13 +- docs/content/cli/plexctl_collection.md | 11 +- docs/content/cli/plexctl_collection_list.md | 11 +- docs/content/cli/plexctl_collection_show.md | 11 +- docs/content/cli/plexctl_device.md | 11 +- docs/content/cli/plexctl_device_list.md | 11 +- docs/content/cli/plexctl_device_show.md | 11 +- docs/content/cli/plexctl_history.md | 11 +- docs/content/cli/plexctl_homeusers.md | 11 +- docs/content/cli/plexctl_homeusers_list.md | 11 +- docs/content/cli/plexctl_hub.md | 11 +- docs/content/cli/plexctl_hub_list.md | 11 +- docs/content/cli/plexctl_library.md | 11 +- docs/content/cli/plexctl_library_list.md | 11 +- docs/content/cli/plexctl_library_refresh.md | 11 +- docs/content/cli/plexctl_library_show.md | 16 +- docs/content/cli/plexctl_login.md | 11 +- docs/content/cli/plexctl_play.md | 14 +- docs/content/cli/plexctl_playlist.md | 11 +- docs/content/cli/plexctl_playlist_list.md | 11 +- docs/content/cli/plexctl_playlist_show.md | 11 +- docs/content/cli/plexctl_search.md | 11 +- docs/content/cli/plexctl_search_find.md | 11 +- docs/content/cli/plexctl_search_reindex.md | 11 +- docs/content/cli/plexctl_search_status.md | 11 +- docs/content/cli/plexctl_server.md | 11 +- docs/content/cli/plexctl_server_discover.md | 11 +- docs/content/cli/plexctl_server_list.md | 11 +- docs/content/cli/plexctl_server_status.md | 11 +- docs/content/cli/plexctl_server_use.md | 11 +- docs/content/cli/plexctl_session.md | 11 +- docs/content/cli/plexctl_session_list.md | 11 +- docs/content/cli/plexctl_session_show.md | 11 +- docs/content/cli/plexctl_session_stop.md | 11 +- docs/content/cli/plexctl_tasks.md | 11 +- docs/content/cli/plexctl_tasks_list.md | 11 +- docs/content/cli/plexctl_tasks_start.md | 11 +- docs/content/cli/plexctl_tasks_stop.md | 11 +- docs/content/cli/plexctl_tui.md | 11 +- docs/content/cli/plexctl_users.md | 11 +- docs/content/cli/plexctl_users_list.md | 11 +- docs/content/cli/plexctl_version.md | 11 +- docs/content/config.mdx | 20 +- docs/content/tui.mdx | 98 ++++- go.mod | 10 +- internal/config/config.go | 3 + internal/config/video.go | 24 + internal/config/video_test.go | 20 + internal/plex/playable.go | 77 ++++ internal/plex/playable_test.go | 68 +++ internal/termgraphics/terminal.go | 235 ++++++++++ internal/termgraphics/terminal_test.go | 95 ++++ internal/termgraphics/writer.go | 76 ++++ internal/tui/artwork/artwork.go | 236 ++++++++++ internal/tui/artwork/artwork_test.go | 85 ++++ internal/tui/navigator.go | 25 ++ internal/tui/navigator_test.go | 38 ++ internal/tui/player/actions.go | 29 +- internal/tui/player/manager.go | 66 ++- internal/tui/player/player_state.go | 1 - internal/tui/player/terminal.go | 415 ++++++++++++++++++ .../tui/player/terminal_integration_test.go | 251 +++++++++++ internal/tui/player/terminal_keys.go | 154 +++++++ internal/tui/player/terminal_keys_test.go | 42 ++ internal/tui/player/terminal_unix.go | 12 + internal/tui/player/terminal_windows.go | 12 + internal/tui/player/video_output.go | 75 ++++ internal/tui/player/video_output_test.go | 54 +++ internal/tui/player/window_load.go | 48 ++ internal/tui/player/window_load_test.go | 61 +++ internal/tui/tui.go | 94 +++- internal/tui/view/detail/base.go | 9 +- internal/tui/view/detail/detail_shared.go | 11 +- internal/tui/view/detail/episode_detail.go | 2 +- internal/tui/view/detail/movie_detail.go | 5 +- internal/tui/view/detail/music_detail.go | 187 ++++++++ internal/tui/view/detail/music_detail_test.go | 69 +++ internal/tui/view/detail/navigation_test.go | 27 ++ internal/tui/view/detail/season_detail.go | 18 +- internal/tui/view/detail/show_detail.go | 18 +- internal/tui/view/history.go | 5 +- internal/tui/view/home.go | 37 +- internal/tui/view/libraries.go | 5 +- internal/tui/view/media.go | 22 +- internal/tui/view/sessions.go | 2 +- internal/tui/view/tasks.go | 2 +- internal/tui/widget/config/config.go | 39 +- internal/tui/widget/config/mouse_test.go | 33 ++ internal/tui/widget/help/help.go | 39 +- internal/tui/widget/iconpicker/common.go | 43 +- internal/tui/widget/navbar/navbar.go | 53 ++- internal/tui/widget/playback/mouse_test.go | 43 ++ internal/tui/widget/playback/playback.go | 112 +++++ internal/tui/widget/poster/grid.go | 27 +- internal/tui/widget/poster/item.go | 11 +- internal/tui/widget/poster/list.go | 23 +- internal/tui/widget/poster/mouse_test.go | 51 +++ internal/tui/widget/resume/resume.go | 40 +- internal/tui/widget/search/overlay.go | 10 +- internal/tui/widget/settings/settings.go | 42 +- internal/tui/widget/userpicker/overlay.go | 16 + internal/ui/mouse.go | 150 +++++++ internal/ui/mouse_test.go | 98 +++++ internal/ui/overlay.go | 6 +- internal/ui/table.go | 80 +++- internal/ui/types.go | 5 +- 110 files changed, 3963 insertions(+), 366 deletions(-) create mode 100644 internal/config/video.go create mode 100644 internal/config/video_test.go create mode 100644 internal/plex/playable.go create mode 100644 internal/plex/playable_test.go create mode 100644 internal/termgraphics/terminal.go create mode 100644 internal/termgraphics/terminal_test.go create mode 100644 internal/termgraphics/writer.go create mode 100644 internal/tui/artwork/artwork.go create mode 100644 internal/tui/artwork/artwork_test.go create mode 100644 internal/tui/navigator_test.go create mode 100644 internal/tui/player/terminal.go create mode 100644 internal/tui/player/terminal_integration_test.go create mode 100644 internal/tui/player/terminal_keys.go create mode 100644 internal/tui/player/terminal_keys_test.go create mode 100644 internal/tui/player/terminal_unix.go create mode 100644 internal/tui/player/terminal_windows.go create mode 100644 internal/tui/player/video_output.go create mode 100644 internal/tui/player/video_output_test.go create mode 100644 internal/tui/player/window_load.go create mode 100644 internal/tui/player/window_load_test.go create mode 100644 internal/tui/view/detail/music_detail.go create mode 100644 internal/tui/view/detail/music_detail_test.go create mode 100644 internal/tui/view/detail/navigation_test.go create mode 100644 internal/tui/widget/config/mouse_test.go create mode 100644 internal/tui/widget/playback/mouse_test.go create mode 100644 internal/tui/widget/playback/playback.go create mode 100644 internal/tui/widget/poster/mouse_test.go create mode 100644 internal/ui/mouse.go create mode 100644 internal/ui/mouse_test.go diff --git a/README.md b/README.md index 144a51b..cc03ee0 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,37 @@ go install github.com/ygelfand/plexctl@latest ### TUI (Interactive) Simply run `plexctl` to launch the interactive terminal interface. +Press **v** on a movie or episode to choose **Kitty**, **Sixel**, or **Text** +playback. **Ctrl+P** starts terminal playback using your saved preference. +The player uses mpv's Kitty or Sixel graphics output when detected, with TCT text +output as a fallback. Press **Space** to pause, use the arrow keys to seek, and +press **q** to return to the library. Regular **p** opens the video window. + +Choose a renderer in **Ctrl+S → Terminal Video**, or override it at launch: + +```bash +plexctl tui --terminal-video kitty +plexctl tui --terminal-video sixel +plexctl play 12345 --terminal-video auto +``` + +Graphics playback requires a compatible terminal and an mpv build that lists the +chosen renderer in `mpv --vo=help`. plexctl probes the attached terminal, including +through tmux, and uses text output when graphics are unavailable. In tmux 3.3+, +playback temporarily zooms its pane and restores the layout on exit. + +Mouse support is enabled automatically: click posters, navigation, and dialog +options; scroll with the wheel; right click to go back. During terminal video, +click to pause, scroll to seek, and right click to return. **V** switches library +list/poster views. + +Movie and TV cover posters automatically use high-resolution images in Kitty +0.28+ (also through tmux), with text posters as a fallback in other terminals. +See the [TUI guide](docs/content/tui.mdx) for configuration and playback details. + +Music libraries support artist → album → track navigation. Press **p** to play +the selected song; playing an artist or album starts its first playable track. + ### CLI (Command Line) `plexctl` provides a comprehensive command-line interface for automation and quick tasks. Every command supports the `-h` or `--help` flag for detailed usage instructions. diff --git a/cmd/play.go b/cmd/play.go index cf98014..e4210b8 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "time" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" "github.com/ygelfand/plexctl/internal/commands" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/player" ) @@ -25,6 +27,18 @@ var playCmd = &cobra.Command{ Args: cobra.ExactArgs(1), GroupID: "media", RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + output := config.VideoWindow + if tctMode { + output = config.VideoTCT + } else if cmd.Flags().Changed("terminal-video") { + output = config.Get().TerminalVideo + } + if output != config.VideoWindow { + // Progress-report diagnostics must not overwrite terminal graphics. + cfg := config.Get() + cfg.LogFile = filepath.Join(cfg.CacheDir, "player.log") + cfg.SetupLogging() + } mediaID := args[0] slog.Debug("CLI Play: Fetching metadata", "mediaID", mediaID) @@ -37,8 +51,12 @@ var playCmd = &cobra.Command{ if trailer { slog.Debug("CLI Play: Resolving trailer", "mediaID", mediaID) - playFunc = player.FetchAndPlayTrailer(meta, tctMode) + playFunc = player.FetchAndPlayTrailer(meta, output) } else { + meta, err = plex.ResolvePlayable(ctx, meta) + if err != nil { + return err + } offset := int64(0) if !noResume && meta.ViewOffset != nil { offset = int64(*meta.ViewOffset) @@ -50,7 +68,7 @@ var playCmd = &cobra.Command{ slog.Info("Playing", "title", meta.Title, "type", meta.Type) } - playFunc = player.PlayMedia(meta, false, tctMode, offset) + playFunc = player.PlayMedia(meta, false, output, offset) } if playFunc == nil { @@ -62,16 +80,16 @@ var playCmd = &cobra.Command{ if err, ok := msg.(error); ok { return err } + if output != config.VideoWindow { + // Terminal playback owns the foreground until EOF or the user's q. + return nil + } title := meta.Title if trailer { title = title + "(Trailer)" } pm := player.GetPlayerManager() - if tctMode { - fmt.Printf("Playing %s in TCT mode. Press 'q' in mpv or Ctrl+C to stop.\n", title) - } else { - fmt.Printf("Playing %s . Press Ctrl+C to stop.\n", title) - } + fmt.Fprintf(cmd.OutOrStdout(), "Playing %s. Press Ctrl+C to stop.\n", title) // Wait loop for CLI to keep progress reporting alive for pm.VerifyConnection() { @@ -89,7 +107,7 @@ var playCmd = &cobra.Command{ func init() { rootCmd.AddCommand(playCmd) - playCmd.Flags().BoolVar(&tctMode, "tct", false, "Use terminal video") + playCmd.Flags().BoolVar(&tctMode, "tct", false, "Use the TCT text renderer (legacy terminal video)") playCmd.Flags().BoolVar(&noResume, "no-resume", false, "Start playback from the beginning") playCmd.Flags().BoolVar(&trailer, "trailer", false, "Play the primary trailer instead of the full media") } diff --git a/cmd/root.go b/cmd/root.go index c1b1aaa..08b1aab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,6 +75,12 @@ func init() { rootCmd.PersistentFlags().StringVar(&sortCol, "sort", "", "column to sort by") viper.BindPFlag("sort", rootCmd.PersistentFlags().Lookup("sort")) + rootCmd.PersistentFlags().String("terminal-video", "auto", "Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct)") + viper.BindPFlag("terminal_video", rootCmd.PersistentFlags().Lookup("terminal-video")) + playCmd.MarkFlagsMutuallyExclusive("tct", "terminal-video") + rootCmd.RegisterFlagCompletionFunc("terminal-video", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"auto", "kitty", "sixel", "tct"}, cobra.ShellCompDirectiveNoFileComp + }) } func initConfig() { @@ -112,6 +118,10 @@ func initConfig() { ui.RenderError(fmt.Errorf("failed to parse config: %w", err)) os.Exit(1) } + if _, err := config.ParseTerminalVideo(string(cfg.TerminalVideo)); err != nil { + ui.RenderError(err) + os.Exit(1) + } // Ensure flags override config if outputType != "" && outputType != "table" { diff --git a/cmd/tui.go b/cmd/tui.go index 6fd6204..e7369f8 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "path/filepath" "time" @@ -12,7 +13,9 @@ import ( "github.com/ygelfand/plexctl/internal/commands" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/termgraphics" "github.com/ygelfand/plexctl/internal/tui" + "github.com/ygelfand/plexctl/internal/tui/artwork" ) var tuiCmd = &cobra.Command{ @@ -38,6 +41,19 @@ func runTUI() error { } resultChan := make(chan plex.LoaderResult, 1) + caps := termgraphics.Probe(os.Stdin, os.Stdout) + slog.Info("Terminal graphics", "terminal", caps.Name, "kitty", caps.Kitty, "sixel", caps.Sixel, + "native_posters", caps.NativePosters(), "tmux", termgraphics.InTmux()) + restore, err := termgraphics.EnablePassthrough() + if err != nil { + slog.Debug("Graphics passthrough unavailable", "error", err) + } + defer restore() + output, err := artwork.Open(os.Stdout, caps.NativePosters()) + if err != nil { + return fmt.Errorf("initialize poster graphics: %w", err) + } + defer output.Close() slog.Debug("TUI: Showing standalone splash") tui.ShowStandaloneSplash(1*time.Second, resultChan) @@ -47,7 +63,7 @@ func runTUI() error { slog.Debug("TUI: Received loader result", "libraries", len(result.Libraries)) slog.Debug("TUI: Initializing controller and program") - p := tea.NewProgram(tui.NewController(result), tea.WithAltScreen()) + p := tea.NewProgram(tui.NewController(result), tea.WithAltScreen(), tea.WithOutput(output), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { slog.Error("TUI: Program run failed", "error", err) diff --git a/docs/content/cli/plexctl.md b/docs/content/cli/plexctl.md index 82c8bcf..677e957 100644 --- a/docs/content/cli/plexctl.md +++ b/docs/content/cli/plexctl.md @@ -13,12 +13,13 @@ plexctl [flags] ### Options ``` - --config string config file (default is $HOME/.plexctl.yaml) - -h, --help help for plexctl - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + -h, --help help for plexctl + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection.md b/docs/content/cli/plexctl_collection.md index 321cf95..c91bf5d 100644 --- a/docs/content/cli/plexctl_collection.md +++ b/docs/content/cli/plexctl_collection.md @@ -11,11 +11,12 @@ Manage collections ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection_list.md b/docs/content/cli/plexctl_collection_list.md index aeb73f6..e48ff23 100644 --- a/docs/content/cli/plexctl_collection_list.md +++ b/docs/content/cli/plexctl_collection_list.md @@ -15,11 +15,12 @@ plexctl collection list [library_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection_show.md b/docs/content/cli/plexctl_collection_show.md index 547c4e2..42ab3c5 100644 --- a/docs/content/cli/plexctl_collection_show.md +++ b/docs/content/cli/plexctl_collection_show.md @@ -15,11 +15,12 @@ plexctl collection show [collection_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device.md b/docs/content/cli/plexctl_device.md index d61cf7f..e4beb76 100644 --- a/docs/content/cli/plexctl_device.md +++ b/docs/content/cli/plexctl_device.md @@ -11,11 +11,12 @@ Manage account devices ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device_list.md b/docs/content/cli/plexctl_device_list.md index 5a41cac..5d59da3 100644 --- a/docs/content/cli/plexctl_device_list.md +++ b/docs/content/cli/plexctl_device_list.md @@ -15,11 +15,12 @@ plexctl device list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device_show.md b/docs/content/cli/plexctl_device_show.md index 50609dd..651a221 100644 --- a/docs/content/cli/plexctl_device_show.md +++ b/docs/content/cli/plexctl_device_show.md @@ -15,11 +15,12 @@ plexctl device show [device_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_history.md b/docs/content/cli/plexctl_history.md index 9abaf0d..567d3d3 100644 --- a/docs/content/cli/plexctl_history.md +++ b/docs/content/cli/plexctl_history.md @@ -16,11 +16,12 @@ plexctl history [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_homeusers.md b/docs/content/cli/plexctl_homeusers.md index d12bff0..36ef489 100644 --- a/docs/content/cli/plexctl_homeusers.md +++ b/docs/content/cli/plexctl_homeusers.md @@ -11,11 +11,12 @@ Manage Plex Home users ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_homeusers_list.md b/docs/content/cli/plexctl_homeusers_list.md index f7d7f0b..1e070c7 100644 --- a/docs/content/cli/plexctl_homeusers_list.md +++ b/docs/content/cli/plexctl_homeusers_list.md @@ -15,11 +15,12 @@ plexctl homeusers list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_hub.md b/docs/content/cli/plexctl_hub.md index 945db66..b56044d 100644 --- a/docs/content/cli/plexctl_hub.md +++ b/docs/content/cli/plexctl_hub.md @@ -11,11 +11,12 @@ Manage hubs ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_hub_list.md b/docs/content/cli/plexctl_hub_list.md index 537cc48..0f21a1a 100644 --- a/docs/content/cli/plexctl_hub_list.md +++ b/docs/content/cli/plexctl_hub_list.md @@ -15,11 +15,12 @@ plexctl hub list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library.md b/docs/content/cli/plexctl_library.md index e0d7281..57753bb 100644 --- a/docs/content/cli/plexctl_library.md +++ b/docs/content/cli/plexctl_library.md @@ -11,11 +11,12 @@ Manage libraries ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_list.md b/docs/content/cli/plexctl_library_list.md index d9ba146..8ecb34f 100644 --- a/docs/content/cli/plexctl_library_list.md +++ b/docs/content/cli/plexctl_library_list.md @@ -15,11 +15,12 @@ plexctl library list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_refresh.md b/docs/content/cli/plexctl_library_refresh.md index b93124e..6515812 100644 --- a/docs/content/cli/plexctl_library_refresh.md +++ b/docs/content/cli/plexctl_library_refresh.md @@ -15,11 +15,12 @@ plexctl library refresh [library_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_show.md b/docs/content/cli/plexctl_library_show.md index ca24704..964f994 100644 --- a/docs/content/cli/plexctl_library_show.md +++ b/docs/content/cli/plexctl_library_show.md @@ -9,17 +9,21 @@ plexctl library show [library_id] [flags] ### Options ``` - -h, --help help for show + --all Return all items (overrides count/page) + --count int Number of items to return per page (default 50) + -h, --help help for show + --page int Page number to return (default 1) ``` ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_login.md b/docs/content/cli/plexctl_login.md index 4df994c..455fd7c 100644 --- a/docs/content/cli/plexctl_login.md +++ b/docs/content/cli/plexctl_login.md @@ -16,11 +16,12 @@ plexctl login [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_play.md b/docs/content/cli/plexctl_play.md index 9f7347a..bb43565 100644 --- a/docs/content/cli/plexctl_play.md +++ b/docs/content/cli/plexctl_play.md @@ -11,17 +11,19 @@ plexctl play [media_id] [flags] ``` -h, --help help for play --no-resume Start playback from the beginning - --tct Use terminal video + --tct Use the TCT text renderer (legacy terminal video) + --trailer Play the primary trailer instead of the full media ``` ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist.md b/docs/content/cli/plexctl_playlist.md index 741a2e4..85ca57c 100644 --- a/docs/content/cli/plexctl_playlist.md +++ b/docs/content/cli/plexctl_playlist.md @@ -11,11 +11,12 @@ Manage playlists ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist_list.md b/docs/content/cli/plexctl_playlist_list.md index 25ff315..b1b2feb 100644 --- a/docs/content/cli/plexctl_playlist_list.md +++ b/docs/content/cli/plexctl_playlist_list.md @@ -15,11 +15,12 @@ plexctl playlist list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist_show.md b/docs/content/cli/plexctl_playlist_show.md index 7171462..4c7d81e 100644 --- a/docs/content/cli/plexctl_playlist_show.md +++ b/docs/content/cli/plexctl_playlist_show.md @@ -15,11 +15,12 @@ plexctl playlist show [playlist_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search.md b/docs/content/cli/plexctl_search.md index de245e1..61e2f3e 100644 --- a/docs/content/cli/plexctl_search.md +++ b/docs/content/cli/plexctl_search.md @@ -11,11 +11,12 @@ Manage and use the library search index ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_find.md b/docs/content/cli/plexctl_search_find.md index 7de4031..eb2535e 100644 --- a/docs/content/cli/plexctl_search_find.md +++ b/docs/content/cli/plexctl_search_find.md @@ -15,11 +15,12 @@ plexctl search find [query] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_reindex.md b/docs/content/cli/plexctl_search_reindex.md index 016f379..5a2fd41 100644 --- a/docs/content/cli/plexctl_search_reindex.md +++ b/docs/content/cli/plexctl_search_reindex.md @@ -15,11 +15,12 @@ plexctl search reindex [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_status.md b/docs/content/cli/plexctl_search_status.md index 8e05ce0..872e9a9 100644 --- a/docs/content/cli/plexctl_search_status.md +++ b/docs/content/cli/plexctl_search_status.md @@ -15,11 +15,12 @@ plexctl search status [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server.md b/docs/content/cli/plexctl_server.md index fa31fb9..7634a85 100644 --- a/docs/content/cli/plexctl_server.md +++ b/docs/content/cli/plexctl_server.md @@ -11,11 +11,12 @@ Manage Plex Server ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_discover.md b/docs/content/cli/plexctl_server_discover.md index acd88d0..9cce14a 100644 --- a/docs/content/cli/plexctl_server_discover.md +++ b/docs/content/cli/plexctl_server_discover.md @@ -15,11 +15,12 @@ plexctl server discover [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_list.md b/docs/content/cli/plexctl_server_list.md index 782a83c..a71dfef 100644 --- a/docs/content/cli/plexctl_server_list.md +++ b/docs/content/cli/plexctl_server_list.md @@ -15,11 +15,12 @@ plexctl server list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_status.md b/docs/content/cli/plexctl_server_status.md index 27f408a..dba40b5 100644 --- a/docs/content/cli/plexctl_server_status.md +++ b/docs/content/cli/plexctl_server_status.md @@ -15,11 +15,12 @@ plexctl server status [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_use.md b/docs/content/cli/plexctl_server_use.md index ee83d3e..6e08f24 100644 --- a/docs/content/cli/plexctl_server_use.md +++ b/docs/content/cli/plexctl_server_use.md @@ -15,11 +15,12 @@ plexctl server use [name_or_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session.md b/docs/content/cli/plexctl_session.md index 83eaed3..af9df74 100644 --- a/docs/content/cli/plexctl_session.md +++ b/docs/content/cli/plexctl_session.md @@ -11,11 +11,12 @@ Manage active playback sessions ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_list.md b/docs/content/cli/plexctl_session_list.md index 78553a3..d8d1fb9 100644 --- a/docs/content/cli/plexctl_session_list.md +++ b/docs/content/cli/plexctl_session_list.md @@ -15,11 +15,12 @@ plexctl session list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_show.md b/docs/content/cli/plexctl_session_show.md index 7653dbf..36c6cde 100644 --- a/docs/content/cli/plexctl_session_show.md +++ b/docs/content/cli/plexctl_session_show.md @@ -15,11 +15,12 @@ plexctl session show [session_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_stop.md b/docs/content/cli/plexctl_session_stop.md index 16cf2cc..e1530c4 100644 --- a/docs/content/cli/plexctl_session_stop.md +++ b/docs/content/cli/plexctl_session_stop.md @@ -15,11 +15,12 @@ plexctl session stop [session_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks.md b/docs/content/cli/plexctl_tasks.md index 657a6f9..82f926b 100644 --- a/docs/content/cli/plexctl_tasks.md +++ b/docs/content/cli/plexctl_tasks.md @@ -11,11 +11,12 @@ Manage background butler tasks ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_list.md b/docs/content/cli/plexctl_tasks_list.md index 7a8dfdb..c38995f 100644 --- a/docs/content/cli/plexctl_tasks_list.md +++ b/docs/content/cli/plexctl_tasks_list.md @@ -15,11 +15,12 @@ plexctl tasks list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_start.md b/docs/content/cli/plexctl_tasks_start.md index d4cf9f3..c7873d0 100644 --- a/docs/content/cli/plexctl_tasks_start.md +++ b/docs/content/cli/plexctl_tasks_start.md @@ -15,11 +15,12 @@ plexctl tasks start [task_name] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_stop.md b/docs/content/cli/plexctl_tasks_stop.md index 6bcf3d7..627b7d2 100644 --- a/docs/content/cli/plexctl_tasks_stop.md +++ b/docs/content/cli/plexctl_tasks_stop.md @@ -15,11 +15,12 @@ plexctl tasks stop [task_name] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tui.md b/docs/content/cli/plexctl_tui.md index e247479..b166635 100644 --- a/docs/content/cli/plexctl_tui.md +++ b/docs/content/cli/plexctl_tui.md @@ -15,11 +15,12 @@ plexctl tui [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_users.md b/docs/content/cli/plexctl_users.md index 5132d52..6dd169f 100644 --- a/docs/content/cli/plexctl_users.md +++ b/docs/content/cli/plexctl_users.md @@ -11,11 +11,12 @@ Manage Plex users ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_users_list.md b/docs/content/cli/plexctl_users_list.md index 8805625..5a119a0 100644 --- a/docs/content/cli/plexctl_users_list.md +++ b/docs/content/cli/plexctl_users_list.md @@ -15,11 +15,12 @@ plexctl users list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_version.md b/docs/content/cli/plexctl_version.md index 8fc6814..4ff972f 100644 --- a/docs/content/cli/plexctl_version.md +++ b/docs/content/cli/plexctl_version.md @@ -15,11 +15,12 @@ plexctl version [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity ``` ### SEE ALSO diff --git a/docs/content/config.mdx b/docs/content/config.mdx index 2e729fe..23b5512 100644 --- a/docs/content/config.mdx +++ b/docs/content/config.mdx @@ -16,6 +16,7 @@ icon_type: "nerdfonts" # Default: emoji (detected) library_name_format: "icon_name" # Default: icon_name default_view_mode: "poster" # Default: poster close_video_on_quit: true # Default: false +terminal_video: "auto" # auto, kitty, sixel, tct cache_dir: "/home/user/.plexctl/cache" # Default: ~/.plexctl/cache default_server: "d9e8f7a6b5c4d3e2f1a0" servers: @@ -75,6 +76,23 @@ The initial layout used when browsing a library. Settings related to how media is played and how data is stored locally. +### `terminal_video` +Selects the renderer used by **Ctrl+P** for full-terminal video playback. +Press **v** to choose a renderer for an individual playback. +- **Default:** `auto` +- **Options:** `auto`, `kitty`, `sixel`, `tct`. +- **TUI:** Change **Terminal Video** in **Ctrl+S** Settings. +- **Override:** `--terminal-video kitty` or `PLEXCTL_TERMINAL_VIDEO=kitty`. + +Auto mode probes the attached terminal, checks which outputs mpv provides, +and falls back to TCT. Graphics require support in both the terminal and mpv; +run `mpv --vo=help` to check the latter. tmux 3.3+ is supported automatically; +unknown terminals use TCT. Plain `p` continues to open a video window. For CLI terminal +playback, pass `plexctl play --terminal-video ` explicitly. + +Terminal playback ends on **q** or at the end of the video and returns to the +library, independently of `close_video_on_quit`. + ### `close_video_on_quit` When exiting `plexctl`, determine if the `mpv` video window should also be closed. - **Default:** `false` @@ -140,4 +158,4 @@ Pressing **`ctrl+l`** opens the Library Configuration overlay. This allows you t - **Custom Icons:** Browse and pick icons for each specific library using an integrated icon picker (supports Emoji and Nerd Fonts). - **Reorder Sidebar:** Use **`K`** and **`J`** (Shift+Up/Down) to drag and drop libraries into your preferred order. -Changes made in these overlays are saved immediately to your `.plexctl.yaml` file. \ No newline at end of file +Changes made in these overlays are saved immediately to your `.plexctl.yaml` file. diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 4ec25ad..33d70bb 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -8,21 +8,39 @@ The plexctl Interactive Interface (TUI) provides a rich, full-screen experience - **`j` / `k` (or Arrows)**: Move cursor up and down. - **`h` / `l` (or Arrows)**: Move through horizontal lists or scroll columns. - **`Enter`**: Select an item or view details. +- **`V`** (uppercase): Switch a library between list and poster views. - **`Esc`**: Go back or close overlays. - **`q`**: Quit the application. +### Mouse + +Mouse support is enabled automatically, including inside tmux: + +- **Left click** a sidebar item, cover poster, table row, search result, or dialog + option to open it. The sidebar and footer actions are clickable too. +- **Wheel** scrolls the view under the pointer: the sidebar, poster grid, Home + sections, tables, synopsis, season/episode lists, or an open dialog. +- **Shift+wheel** or a horizontal wheel browses a Home poster row. +- **Right click** goes back one level or closes the current dialog. Clicks outside + an open dialog do not activate the library behind it. +- Hold **Shift** while selecting text to use the terminal's own selection. + +During terminal playback, **left click** pauses/resumes, **wheel up/down** seeks +forward/backward, and **right click** stops and returns to the library. These use +mpv's Space, Right/Left, and q bindings, including any custom bindings you set. + ## Global Shortcuts - **`u`**: Switch User (Plex Home). - **`p`**: Play selected item. -- **`ctrl+p`**: Play selected item in TCT mode (terminal-based video rendering). +- **`v`**: Choose terminal playback: Automatic, Kitty, Sixel, or Text. +- **`ctrl+p`**: Stream the selected item in the terminal (Kitty, Sixel, or TCT). - **`/`**: Open global fuzzy search. - **`ctrl+l`**: Open library configuration (show/hide/icon picker). - **`ctrl+s`**: Open global settings (theme/icon type). - **`r`**: Refresh current view / trigger reindex (on search page). - **`x`**: Stop current playback. - **`h`**: Jump directly to Home tab. -- **`u`**: Switch User (Plex Home). - **`?`**: Show help overlay. ## Features @@ -37,11 +55,85 @@ Pressing `ctrl+l` opens the configuration manager. Here you can: - **Reorder** how libraries appear in your sidebar. ### Integrated Player + +Music libraries open **artists → albums → tracks**. Click an item or press +**Enter** to browse, then press **`p`** to play the selected track. Playing an +artist or album directly starts its first playable track; open the album to +choose another song. **Esc** or right click returns to the previous level. + When playback is started, a playback bar appears at the bottom showing: - Current progress and duration. - Play/Pause status. - Media title. -You can control the player directly from the TUI using `space` to pause/resume and `x` to stop. +You can control the player directly from the TUI using `space` or clicking the +player bar to pause/resume, and `x` or the footer's **Stop** action to stop. + +### Terminal Video + +Press **`v`** on a movie or episode to open the terminal playback menu. Select +**Kitty graphics**, **Sixel graphics**, **Text**, or **Automatic**. The menu shows +the detected terminal capabilities. **`ctrl+p`** uses the preference saved in +Settings. Playback takes over the full terminal; the library view returns when +the video ends or you press **`q`**. During playback, mpv's keyboard bindings apply: + +- **`Space`** or **left click**: Pause or resume. +- **`Left` / `Right`** or **wheel**: Seek backward or forward. +- **`9` / `0`**: Decrease or increase volume. +- **`j`**: Cycle subtitle tracks. +- **`q`** or **right click**: Stop and return to the library. + +The resume prompt, external subtitle tracks, and Plex watch progress also work +with terminal playback. The library's `x` shortcut applies to windowed playback; +use `q` while terminal video is playing. + +Choose **Terminal Video** in **`ctrl+s`** Settings: + +| Renderer | Behavior | +| --- | --- | +| `auto` (default) | Probe terminal graphics support and check mpv's compiled outputs; fall back to TCT. | +| `kitty` | Use the Kitty graphics protocol. Requires a compatible terminal. | +| `sixel` | Use Sixel graphics. Requires Sixel support enabled in the terminal. | +| `tct` | Use mpv's true-color text renderer. | + +Auto detection queries the attached terminal, including through tmux. Kitty is +preferred when supported, followed by Sixel, then TCT. If a terminal does not +answer, known terminal environment variables are used outside multiplexers. +Check your mpv build with **`mpv --vo=help`**. An explicitly selected renderer +that is missing from mpv produces an error instead of silently changing output. + +You can override the preference for a launch or play directly from the CLI: + +```bash +plexctl tui --terminal-video kitty +plexctl tui --terminal-video sixel +plexctl play 12345 --terminal-video auto +plexctl play 12345 --terminal-video sixel --no-resume +``` + +The preference is stored as `terminal_video` in `~/.plexctl.yaml` and can also be +set with `PLEXCTL_TERMINAL_VIDEO`. Plain `p` and `plexctl play 12345` still open a +video window. The existing `plexctl play 12345 --tct` explicitly selects TCT. + +Kitty output uses escape-code transport, so it does not require shared memory +with the terminal when using SSH. With tmux 3.3+, plexctl temporarily enables +graphics passthrough for its own pane. During graphics playback it zooms that +pane so video cannot cover neighboring panes, then restores the layout and +passthrough setting on exit. Older mpv releases are supported by forwarding +graphics and input through plexctl. GNU screen uses TCT in auto mode. +Rendering speed and image quality depend on terminal size, connection speed, +and mpv. Installing Kitty alone does not enable graphics in another emulator; +launch plexctl (or attach tmux) from the Kitty application. +See [mpv's video output documentation](https://mpv.io/manual/stable/#video-output-drivers) +for terminal requirements and configuration. + +### High-resolution cover posters + +In Kitty 0.28+, using Unicode image placeholders, movie +and TV posters automatically use native images, retaining up to 768 pixels on +their longest side instead of reducing the cover to 15 colored text cells. +This works in grids, details, and through tmux. Posters scroll and clip with the +TUI and are restored after video playback. Other terminals retain text posters. +No cache clearing or configuration change is required. ### User Switching (Plex Home) Pressing `u` opens the "Who's watching?" profile picker. This allows you to switch between managed users in your Plex Home. diff --git a/go.mod b/go.mod index 3bb5d44..f96837c 100644 --- a/go.mod +++ b/go.mod @@ -37,9 +37,9 @@ require ( github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect - golang.org/x/image v0.38.0 // indirect + golang.org/x/image v0.38.0 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce ) require ( @@ -49,9 +49,9 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect - github.com/charmbracelet/x/ansi v0.11.4 // indirect + github.com/charmbracelet/x/ansi v0.11.4 github.com/charmbracelet/x/cellbuf v0.0.14 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/term v0.2.2 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/doordash-oss/oapi-codegen-dd/v3 v3.66.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -71,7 +71,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/cancelreader v0.2.2 github.com/muesli/termenv v0.16.0 // indirect github.com/pb33f/jsonpath v0.7.0 // indirect github.com/pb33f/libopenapi v0.31.2 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index 4cc097b..71f7289 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,6 +113,7 @@ type Config struct { DefaultToTui bool `mapstructure:"default_to_tui"` AutoHomeLogin bool `mapstructure:"auto_home_login"` CloseVideoOnQuit bool `mapstructure:"close_video_on_quit"` + TerminalVideo VideoOutput `mapstructure:"terminal_video"` // Server management DefaultServer string `mapstructure:"default_server"` // Stores the ClientIdentifier @@ -149,6 +150,7 @@ func Get() *Config { DefaultToTui: true, AutoHomeLogin: true, DefaultViewMode: ViewModePoster, + TerminalVideo: VideoAuto, } }) return instance @@ -214,6 +216,7 @@ func (c *Config) Save() error { viper.Set("default_to_tui", c.DefaultToTui) viper.Set("auto_home_login", c.AutoHomeLogin) viper.Set("close_video_on_quit", c.CloseVideoOnQuit) + viper.Set("terminal_video", c.TerminalVideo) viper.Set("cache_dir", c.CacheDir) viper.Set("default_server", c.DefaultServer) viper.Set("servers", c.Servers) diff --git a/internal/config/video.go b/internal/config/video.go new file mode 100644 index 0000000..13758fd --- /dev/null +++ b/internal/config/video.go @@ -0,0 +1,24 @@ +package config + +import "fmt" + +// VideoOutput selects an mpv video renderer. The zero value opens a video window. +type VideoOutput string + +const ( + VideoWindow VideoOutput = "" + VideoAuto VideoOutput = "auto" + VideoKitty VideoOutput = "kitty" + VideoSixel VideoOutput = "sixel" + VideoTCT VideoOutput = "tct" +) + +// ParseTerminalVideo validates a terminal renderer preference. +func ParseTerminalVideo(value string) (VideoOutput, error) { + switch output := VideoOutput(value); output { + case VideoAuto, VideoKitty, VideoSixel, VideoTCT: + return output, nil + default: + return VideoWindow, fmt.Errorf("invalid terminal video renderer %q: choose auto, kitty, sixel, or tct", value) + } +} diff --git a/internal/config/video_test.go b/internal/config/video_test.go new file mode 100644 index 0000000..cb22913 --- /dev/null +++ b/internal/config/video_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +func TestParseTerminalVideo(t *testing.T) { + t.Parallel() + for _, value := range []string{"auto", "kitty", "sixel", "tct", "", "gpu", "kitty,sixel", "--vo=gpu"} { + t.Run(value, func(t *testing.T) { + t.Parallel() + got, err := ParseTerminalVideo(value) + valid := value == "auto" || value == "kitty" || value == "sixel" || value == "tct" + if valid && (err != nil || string(got) != value) { + t.Fatalf("ParseTerminalVideo(%q) = %q, %v", value, got, err) + } + if !valid && err == nil { + t.Fatalf("accepted invalid renderer %q", value) + } + }) + } +} diff --git a/internal/plex/playable.go b/internal/plex/playable.go new file mode 100644 index 0000000..8f7789c --- /dev/null +++ b/internal/plex/playable.go @@ -0,0 +1,77 @@ +package plex + +import ( + "context" + "fmt" + + "github.com/LukeHagar/plexgo/models/components" +) + +// PlayablePart finds the first version with a streamable file. Container items +// such as artists and albums have children instead of Media/Part entries. +func PlayablePart(meta *components.Metadata) *components.Part { + if meta != nil { + for i := range meta.Media { + for j := range meta.Media[i].Part { + part := &meta.Media[i].Part[j] + if part.Key != "" { + return part + } + } + } + } + return nil +} + +// ResolvePlayable resolves an artist or album to its first playable track in +// Plex's library order. Individual tracks and videos retain their own identity. +func ResolvePlayable(ctx context.Context, meta *components.Metadata) (*components.Metadata, error) { + visited := make(map[string]bool) + var find func(*components.Metadata) (*components.Metadata, error) + find = func(item *components.Metadata) (*components.Metadata, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if PlayablePart(item) != nil { + return item, nil + } + if item == nil || item.RatingKey == nil || visited[*item.RatingKey] { + return nil, nil + } + visited[*item.RatingKey] = true + if item.Type != "artist" && item.Type != "album" { + return nil, nil + } + children, err := GetChildren(ctx, *item.RatingKey) + if err != nil { + return nil, err + } + for i := range children { + child := &children[i] + if child.RatingKey == nil { + continue + } + if child.Type == "track" { + child, err = GetMetadata(ctx, *child.RatingKey, true) + if err != nil { + return nil, err + } + } else if child.Type != "album" { + continue + } + playable, err := find(child) + if err != nil || playable != nil { + return playable, err + } + } + return nil, nil + } + playable, err := find(meta) + if err != nil || playable != nil { + return playable, err + } + if meta != nil && (meta.Type == "artist" || meta.Type == "album") { + return nil, fmt.Errorf("no playable tracks found in this %s", meta.Type) + } + return nil, fmt.Errorf("no playable media found") +} diff --git a/internal/plex/playable_test.go b/internal/plex/playable_test.go new file mode 100644 index 0000000..c2beb34 --- /dev/null +++ b/internal/plex/playable_test.go @@ -0,0 +1,68 @@ +package plex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/config" +) + +func TestResolveMusicPlayback(t *testing.T) { + track := components.Metadata{RatingKey: ptr("track"), Type: "track", Title: "Song", Media: []components.Media{ + {}, // Plex can list an unavailable version before a playable one. + {Part: []components.Part{{}, {Key: "/music.flac"}}}, + }} + artist := components.Metadata{RatingKey: ptr("artist"), Type: "artist"} + album := components.Metadata{RatingKey: ptr("album"), Type: "album"} + empty := components.Metadata{RatingKey: ptr("empty"), Type: "album"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var items []components.Metadata + switch r.URL.Path { + case "/library/metadata/artist/children": + items = []components.Metadata{empty, album} + case "/library/metadata/album/children": + // Library listings need not include the track's Media/Part. + items = []components.Metadata{{RatingKey: ptr("track"), Type: "track", Title: "Song"}} + case "/library/metadata/track": + items = []components.Metadata{track} + case "/library/metadata/empty/children": + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": map[string]any{"Metadata": items}}) + })) + defer server.Close() + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.DefaultServer = "music-test" + cfg.Servers = map[string]config.Server{"music-test": {URL: server.URL}} + cfg.CacheDir = t.TempDir() + cfg.NoCache = true + + for _, meta := range []components.Metadata{artist, album, track} { + t.Run(meta.Type, func(t *testing.T) { + got, err := ResolvePlayable(context.Background(), &meta) + if err != nil { + t.Fatal(err) + } + if got.RatingKey == nil || *got.RatingKey != "track" || PlayablePart(got).Key != "/music.flac" { + t.Fatalf("did not resolve the playable track: %+v", got) + } + }) + } + if _, err := ResolvePlayable(context.Background(), &empty); err == nil || !strings.Contains(err.Error(), "no playable tracks") { + t.Fatalf("empty album should have an actionable error, got %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ResolvePlayable(ctx, &artist); err == nil { + t.Fatal("canceled traversal succeeded") + } +} diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go new file mode 100644 index 0000000..9f13be9 --- /dev/null +++ b/internal/termgraphics/terminal.go @@ -0,0 +1,235 @@ +// Package termgraphics detects terminal graphics and handles tmux passthrough. +package termgraphics + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/muesli/cancelreader" + "golang.org/x/term" +) + +type Capabilities struct { + Responded bool + Kitty bool + Sixel bool + Name string +} + +// NativePosters requires Unicode placeholders, which basic Kitty graphics +// support alone does not guarantee (notably on older Kitty and other emulators). +func (c Capabilities) NativePosters() bool { + if !c.Kitty || !strings.HasPrefix(c.Name, "kitty(") { + return false + } + version := strings.Split(strings.TrimSuffix(strings.TrimPrefix(c.Name, "kitty("), ")"), ".") + if len(version) < 2 { + return false + } + major, e1 := strconv.Atoi(version[0]) + minor, e2 := strconv.Atoi(version[1]) + return e1 == nil && e2 == nil && (major > 0 || major == 0 && minor >= 28) +} + +var detected struct { + sync.RWMutex + caps Capabilities +} + +var tmuxTerminal struct { + sync.Once + active bool +} + +// InTmux checks the pane's actual TTY. A new Kitty window launched from a tmux +// shell inherits TMUX and TMUX_PANE but must receive unwrapped graphics. +func InTmux() bool { + if os.Getenv("TMUX") == "" { + return false + } + tmuxTerminal.Do(func() { + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + tty, err := exec.CommandContext(ctx, "tmux", "display-message", "-p", "-t", pane, "#{pane_tty}").Output() + if err == nil { + tmuxTerminal.active = sameTerminal(strings.TrimSpace(string(tty)), os.Stdin, os.Stdout) + } + }) + return tmuxTerminal.active +} + +func sameTerminal(path string, streams ...*os.File) bool { + pane, err := os.Stat(path) + if err != nil { + return false + } + for _, stream := range streams { + if info, err := stream.Stat(); err == nil && os.SameFile(pane, info) { + return true + } + } + return false +} + +func Current() Capabilities { + detected.RLock() + defer detected.RUnlock() + return detected.caps +} + +// EnablePassthrough changes only the current pane, and returns a restoration +// function. An inherited option is restored by removing the local override. +func EnablePassthrough() (func(), error) { + noop := func() {} + if !InTmux() { + return noop, nil + } + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return noop, fmt.Errorf("TMUX_PANE is missing; run plexctl inside a tmux pane") + } + run := func(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).Output() + } + old, err := run("show-options", "-p", "-v", "-t", pane, "allow-passthrough") + if err != nil { + return noop, fmt.Errorf("tmux graphics require tmux 3.3 or newer: %w", err) + } + value := strings.TrimSpace(string(old)) + if value == "on" || value == "all" { + return noop, nil + } + if _, err := run("set-option", "-p", "-t", pane, "allow-passthrough", "on"); err != nil { + return noop, fmt.Errorf("enable tmux graphics passthrough: %w", err) + } + return func() { + if value == "" { + _, _ = run("set-option", "-p", "-u", "-t", pane, "allow-passthrough") + } else { + _, _ = run("set-option", "-p", "-t", pane, "allow-passthrough", value) + } + }, nil +} + +func Wrap(sequence string) string { + if InTmux() { + return ansi.TmuxPassthrough(sequence) + } + return sequence +} + +// ZoomPane gives passthrough video the terminal area while it owns input. tmux +// cannot clip a passthrough image to a split pane. Restore the previous layout +// after playback, including on a player error. +func ZoomPane() (func(), error) { + noop := func() {} + if !InTmux() { + return noop, nil + } + pane := os.Getenv("TMUX_PANE") + run := func(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).Output() + } + zoom, err := run("display-message", "-p", "-t", pane, "#{window_zoomed_flag}") + if err != nil { + return noop, fmt.Errorf("inspect tmux pane: %w", err) + } + if strings.TrimSpace(string(zoom)) == "1" { + return noop, nil + } + if _, err := run("resize-pane", "-Z", "-t", pane); err != nil { + return noop, fmt.Errorf("zoom terminal playback pane: %w", err) + } + return func() { + zoom, err := run("display-message", "-p", "-t", pane, "#{window_zoomed_flag}") + if err == nil && strings.TrimSpace(string(zoom)) == "1" { + _, _ = run("resize-pane", "-Z", "-t", pane) + } + }, nil +} + +var attributes = regexp.MustCompile(`\x1b\[\?([0-9;]+)c`) +var terminalName = regexp.MustCompile(`\x1bP>\|([^\x1b]+)\x1b\\`) + +func parseCapabilities(reply string) Capabilities { + c := Capabilities{Kitty: strings.Contains(reply, "_Gi=18901;OK")} + if match := attributes.FindStringSubmatch(reply); match != nil { + c.Responded = true + for _, feature := range strings.Split(match[1], ";")[1:] { + c.Sixel = c.Sixel || feature == "4" + } + } + if match := terminalName.FindStringSubmatch(reply); match != nil { + c.Name = match[1] + c.Responded = true + } + c.Responded = c.Responded || c.Kitty + return c +} + +// Probe must run while the caller owns terminal input (before Bubble Tea starts +// or while it is suspended). A missing response is unknown, not rejection. +func Probe(input *os.File, output io.Writer) Capabilities { + if !term.IsTerminal(int(input.Fd())) { + return Current() + } + restore, err := EnablePassthrough() + if err != nil { + return Current() + } + defer restore() + state, err := term.MakeRaw(int(input.Fd())) + if err != nil { + return Current() + } + defer term.Restore(int(input.Fd()), state) + r, err := cancelreader.NewReader(input) + if err != nil { + return Current() + } + var reply strings.Builder + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 1024) + for reply.Len() < 8192 { + n, err := r.Read(buf) + reply.Write(buf[:n]) + if err != nil { + return + } + } + }() + _, _ = io.WriteString(output, Wrap("\x1b_Ga=q,U=1,i=18901,s=1,v=1,f=24;AAAA\x1b\\\x1b[c\x1b[>q")) + timer := time.NewTimer(350 * time.Millisecond) + select { + case <-done: + case <-timer.C: + } + timer.Stop() + r.Cancel() + <-done + _ = r.Close() + caps := parseCapabilities(reply.String()) + detected.Lock() + detected.caps = caps + detected.Unlock() + return caps +} diff --git a/internal/termgraphics/terminal_test.go b/internal/termgraphics/terminal_test.go new file mode 100644 index 0000000..6f7935a --- /dev/null +++ b/internal/termgraphics/terminal_test.go @@ -0,0 +1,95 @@ +package termgraphics + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestTmuxMustOwnCurrentTerminal(t *testing.T) { + dir := t.TempDir() + parent, err := os.Create(filepath.Join(dir, "parent-pane")) + if err != nil { + t.Fatal(err) + } + defer parent.Close() + direct, err := os.Create(filepath.Join(dir, "direct-kitty")) + if err != nil { + t.Fatal(err) + } + defer direct.Close() + if sameTerminal(parent.Name(), direct, direct) { + t.Fatal("an inherited TMUX_PANE redirected a new terminal's graphics") + } + if !sameTerminal(parent.Name(), parent, direct) || !sameTerminal(parent.Name(), direct, parent) { + t.Fatal("the actual tmux pane was not recognized") + } + if sameTerminal(filepath.Join(dir, "closed-pane"), direct, direct) { + t.Fatal("a stale pane was recognized") + } +} + +func TestCapabilities(t *testing.T) { + for _, tt := range []struct { + name, reply string + want Capabilities + }{ + {"unknown", "", Capabilities{}}, + {"vte", "\x1b[?61;1;21;22c\x1bP>|VTE(7600)\x1b\\", Capabilities{Responded: true, Name: "VTE(7600)"}}, + {"kitty", "\x1b_Gi=18901;OK\x1b\\\x1b[?62;4;22c", Capabilities{Responded: true, Kitty: true, Sixel: true}}, + {"sixel", "\x1b[?64;1;4;22c", Capabilities{Responded: true, Sixel: true}}, + {"not four", "\x1b[?64;14;24c", Capabilities{Responded: true}}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := parseCapabilities(tt.reply); got != tt.want { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestTmuxPacketsSplitAcrossWrites(t *testing.T) { + kitty := "\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\" + sixel := "\x1bPq#0;2;100;0;0~\x1b\\" + input := "\x1b[?1049h\x1b[2;3H" + kitty + sixel + "\x1b[?1049l" + want := "\x1b[?1049h\x1b[2;3H" + ansi.TmuxPassthrough(kitty) + ansi.TmuxPassthrough(sixel) + "\x1b[?1049l" + for split := 1; split <= len(input); split++ { + var output bytes.Buffer + w := Writer{Output: &output} + for at := 0; at < len(input); at += split { + p := []byte(input[at:min(at+split, len(input))]) + if n, err := w.Write(p); err != nil || n != len(p) { + t.Fatalf("write: %d, %v", n, err) + } + } + if output.String() != want { + t.Fatalf("chunk size %d corrupted graphics or cursor movement", split) + } + } +} + +func TestKittyPlaybackImageOwnership(t *testing.T) { + var output bytes.Buffer + w := Writer{Output: &output, ImageID: 1073741824} + w.Write([]byte("\x1b_Ga=d;\x1b\\\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\")) + want := ansi.TmuxPassthrough(DeleteImage(1073741824)) + ansi.TmuxPassthrough("\x1b_Ga=T,f=24,s=1,v=1,i=1073741824;AAAA\x1b\\") + if output.String() != want { + t.Fatal("video could overwrite or delete other applications' images") + } +} + +func TestNativePostersRequireUnicodePlaceholders(t *testing.T) { + for _, tt := range []struct { + name string + want bool + }{ + {"kitty(0.27.0)", false}, {"kitty(0.28.0)", true}, {"kitty(0.49.2)", true}, {"kitty(1.0.0)", true}, {"VTE(7600)", false}, {"", false}, + } { + if got := (Capabilities{Kitty: true, Name: tt.name}).NativePosters(); got != tt.want { + t.Errorf("%s: got %v", tt.name, got) + } + } +} diff --git a/internal/termgraphics/writer.go b/internal/termgraphics/writer.go new file mode 100644 index 0000000..5d4515d --- /dev/null +++ b/internal/termgraphics/writer.go @@ -0,0 +1,76 @@ +package termgraphics + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Writer wraps mpv's graphics packets for tmux, retaining ANSI cursor movement +// and screen switching in tmux itself so playback stays inside its pane. +// Packets can span arbitrary writes from os/exec's stdout pipe. +type Writer struct { + Output io.Writer + // ImageID assigns frames to this playback session for targeted cleanup. + ImageID int + pending []byte +} + +func (w *Writer) Write(p []byte) (int, error) { + w.pending = append(w.pending, p...) + for len(w.pending) > 0 { + start := bytes.IndexByte(w.pending, '\x1b') + if start < 0 { + start = len(w.pending) + } + if start > 0 { + if _, err := w.Output.Write(w.pending[:start]); err != nil { + return 0, err + } + w.pending = w.pending[start:] + continue + } + if len(w.pending) < 2 { + break + } + if w.pending[1] != '_' && w.pending[1] != 'P' { + if _, err := w.Output.Write(w.pending[:2]); err != nil { + return 0, err + } + w.pending = w.pending[2:] + continue + } + end := bytes.Index(w.pending[2:], []byte("\x1b\\")) + if end < 0 { + if len(w.pending) > 32<<20 { + return 0, fmt.Errorf("terminal graphics packet exceeds 32 MiB") + } + break + } + end += 4 + sequence := string(w.pending[:end]) + if w.ImageID != 0 && strings.HasPrefix(sequence, "\x1b_G") { + if head, payload, ok := strings.Cut(sequence, ";"); ok { + switch { + case strings.Contains(head, "a=T"): + sequence = fmt.Sprintf("%s,i=%d;%s", head, w.ImageID, payload) + case strings.Contains(head, "a=d"): + sequence = DeleteImage(w.ImageID) + } + } + } + packet := ansi.TmuxPassthrough(sequence) + if _, err := io.WriteString(w.Output, packet); err != nil { + return 0, err + } + w.pending = w.pending[end:] + } + return len(p), nil +} + +func DeleteImage(id int) string { + return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id) +} diff --git a/internal/tui/artwork/artwork.go b/internal/tui/artwork/artwork.go new file mode 100644 index 0000000..9571810 --- /dev/null +++ b/internal/tui/artwork/artwork.go @@ -0,0 +1,236 @@ +// Package artwork renders native Kitty posters using Unicode placeholders. +// Placeholders remain ordinary cells to Bubble Tea and tmux, so scrolling, +// clipping, borders, and overlays work without an independent image layer. +package artwork + +import ( + "fmt" + "image" + "io" + "math/rand/v2" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi/kitty" + gopixels "github.com/saran13raj/go-pixels" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/termgraphics" + "golang.org/x/image/draw" +) + +var posters = struct { + sync.Mutex + enabled bool + dir string + nextID int + views map[string]string + files map[int]string + wanted map[int]bool + sent map[int]uint64 + clock uint64 +}{} + +// Open enables native posters only after a successful Kitty protocol probe. +// Image payloads live in a temporary directory, rather than accumulating in RAM +// or persisting terminal-specific image IDs in the old text-poster cache. +func Open(output *os.File, enabled bool) (*Output, error) { + posters.Lock() + defer posters.Unlock() + posters.enabled = enabled + posters.views = make(map[string]string) + posters.files = make(map[int]string) + posters.wanted = make(map[int]bool) + posters.sent = make(map[int]uint64) + posters.nextID = 1 + rand.IntN(1<<22) + if enabled { + dir, err := os.MkdirTemp("", "plexctl-posters-") + if err != nil { + posters.enabled = false + return nil, err + } + posters.dir = dir + } + return &Output{file: output}, nil +} + +func key(ratingKey string, width int) string { + server, _, _ := config.Get().GetActiveServer() + return fmt.Sprintf("%s/%s/%d", server, ratingKey, width) +} + +func Cached(ratingKey string, width int) (string, bool) { + posters.Lock() + defer posters.Unlock() + if posters.enabled { + view, ok := posters.views[key(ratingKey, width)] + return view, ok + } + return plex.GetCachedPoster(ratingKey, width) +} + +func Render(ratingKey string, img image.Image, width, height int) (string, error) { + posters.Lock() + enabled, dir := posters.enabled, posters.dir + id := posters.nextID + posters.nextID++ + posters.Unlock() + if !enabled { + view, err := gopixels.FromImageStream(img, width, 0, "halfcell", true) + if err == nil && ratingKey != "" { + plex.SetCachedPoster(ratingKey, width, view) + } + return view, err + } + if width <= 0 || width > 255 { + return "", fmt.Errorf("invalid poster width: %d", width) + } + if height <= 0 { + height = max(1, (width*img.Bounds().Dy()/img.Bounds().Dx()+1)/2) + } + height = min(height, 255) + // Retain hundreds of pixels of detail, including on HiDPI displays, while + // bounding transport and terminal texture memory for very large originals. + if longest := max(img.Bounds().Dx(), img.Bounds().Dy()); longest > 768 { + resized := image.NewRGBA(image.Rect(0, 0, + max(1, img.Bounds().Dx()*768/longest), max(1, img.Bounds().Dy()*768/longest))) + draw.CatmullRom.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Over, nil) + img = resized + } + path := filepath.Join(dir, strconv.Itoa(id)) + f, err := os.Create(path) + if err != nil { + return "", err + } + err = kitty.EncodeGraphics(f, img, &kitty.Options{ + Action: kitty.TransmitAndPut, Quite: 2, ID: id, Format: kitty.PNG, + Transmission: kitty.Direct, Chunk: true, ChunkFormatter: termgraphics.Wrap, + Columns: width, Rows: height, VirtualPlacement: true, + }) + closeErr := f.Close() + if err != nil { + return "", err + } + if closeErr != nil { + return "", closeErr + } + view := placeholders(id, width, height) + posters.Lock() + posters.files[id] = path + posters.views[key(ratingKey, width)] = view + posters.Unlock() + return view, nil +} + +func placeholders(id, columns, rows int) string { + var s strings.Builder + for y := range rows { + if y > 0 { + s.WriteByte('\n') + } + fmt.Fprintf(&s, "\x1b[38;2;%d;%d;%dm\x1b[59m", id>>16&255, id>>8&255, id&255) + for x := range columns { + s.WriteRune(kitty.Placeholder) + s.WriteRune(kitty.Diacritic(y)) + s.WriteRune(kitty.Diacritic(x)) + s.WriteRune(kitty.Diacritic(0)) + } + s.WriteString("\x1b[39m") + } + return s.String() +} + +var imageColor = regexp.MustCompile(`\x1b\[38;2;(\d+);(\d+);(\d+)m`) + +// Frame records images in the final, clipped/composited view. The output writer +// sends their payloads before the next screen update, on the renderer's own +// goroutine. Background image fetches never write directly to the terminal. +func Frame(view string) string { + posters.Lock() + defer posters.Unlock() + if !posters.enabled { + return view + } + wanted := make(map[int]bool) + for _, m := range imageColor.FindAllStringSubmatch(view, -1) { + r, _ := strconv.Atoi(m[1]) + g, _ := strconv.Atoi(m[2]) + b, _ := strconv.Atoi(m[3]) + id := r<<16 | g<<8 | b + if _, ok := posters.files[id]; ok { + wanted[id] = true + } + } + posters.wanted = wanted + return view +} + +type Output struct{ file *os.File } + +func (o *Output) Fd() uintptr { return o.file.Fd() } + +func (o *Output) Read(p []byte) (int, error) { return o.file.Read(p) } + +// TerminalFile keeps mpv attached to a real TTY when Bubble Tea lends it output. +func (o *Output) TerminalFile() *os.File { return o.file } + +func (o *Output) Write(p []byte) (int, error) { + posters.Lock() + defer posters.Unlock() + if strings.Contains(string(p), "\x1b[?1049l") { + // An external player may clear graphics while Bubble Tea is suspended. + clear(posters.sent) + return o.file.Write(p) + } + for id := range posters.wanted { + posters.clock++ + if _, ok := posters.sent[id]; !ok { + data, err := os.ReadFile(posters.files[id]) + if err != nil { + return 0, err + } + if _, err := o.file.Write(data); err != nil { + return 0, err + } + } + posters.sent[id] = posters.clock + } + // Bound terminal texture memory; inactive posters can be uploaded again. + for len(posters.sent) > max(64, len(posters.wanted)) { + oldest, age := 0, ^uint64(0) + for id, used := range posters.sent { + if !posters.wanted[id] && used < age { + oldest, age = id, used + } + } + if oldest == 0 { + break + } + _, _ = io.WriteString(o.file, deleteImage(oldest)) + delete(posters.sent, oldest) + } + return o.file.Write(p) +} + +func deleteImage(id int) string { + return termgraphics.Wrap(fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)) +} + +func (o *Output) Close() error { + posters.Lock() + defer posters.Unlock() + for id := range posters.files { + _, _ = io.WriteString(o.file, deleteImage(id)) + } + posters.enabled = false + clear(posters.wanted) + clear(posters.files) + if posters.dir != "" { + return os.RemoveAll(posters.dir) + } + return nil +} diff --git a/internal/tui/artwork/artwork_test.go b/internal/tui/artwork/artwork_test.go new file mode 100644 index 0000000..7818f75 --- /dev/null +++ b/internal/tui/artwork/artwork_test.go @@ -0,0 +1,85 @@ +package artwork + +import ( + "bytes" + "encoding/base64" + "image" + "image/png" + "os" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi/kitty" + term "github.com/charmbracelet/x/term" +) + +var _ term.File = (*Output)(nil) + +func TestNativePosterLifecycle(t *testing.T) { + t.Setenv("TMUX", "") + f, err := os.CreateTemp(t.TempDir(), "terminal") + if err != nil { + t.Fatal(err) + } + defer f.Close() + w, err := Open(f, true) + if err != nil { + t.Fatal(err) + } + defer w.Close() + view, err := Render("test", image.NewRGBA(image.Rect(0, 0, 600, 900)), 15, 10) + if err != nil { + t.Fatal(err) + } + if lipgloss.Width(view) != 15 || lipgloss.Height(view) != 10 || !strings.ContainsRune(view, kitty.Placeholder) { + t.Fatal("incorrect placeholder dimensions") + } + frame := Frame(lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Render(view)) + if len(posters.wanted) != 1 { + t.Fatal("image lost when composing frame") + } + if _, err := w.Write([]byte(frame)); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + var encoded strings.Builder + for _, m := range regexp.MustCompile(`\x1b_G[^;]*;([^\x1b]*)\x1b\\`).FindAllSubmatch(data, -1) { + encoded.Write(m[1]) + } + pixels, err := base64.StdEncoding.DecodeString(encoded.String()) + if err != nil { + t.Fatal(err) + } + dimensions, err := png.DecodeConfig(bytes.NewReader(pixels)) + if err != nil { + t.Fatal(err) + } + if dimensions.Width != 512 || dimensions.Height != 768 { + t.Fatalf("poster lost image detail: %+v", dimensions) + } + before, _ := f.Stat() + w.Write([]byte(frame)) + after, _ := f.Stat() + if after.Size()-before.Size() != int64(len(frame)) { + t.Fatal("poster uploaded again without a change") + } + w.Write([]byte("\x1b[?1049l")) + before, _ = f.Stat() + w.Write([]byte(frame)) + after, _ = f.Stat() + if after.Size()-before.Size() <= int64(len(frame)) { + t.Fatal("poster not reuploaded after returning from playback") + } + dir := posters.dir + if err := w.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("temporary poster data left behind") + } +} diff --git a/internal/tui/navigator.go b/internal/tui/navigator.go index c4444df..1062f5e 100644 --- a/internal/tui/navigator.go +++ b/internal/tui/navigator.go @@ -3,6 +3,7 @@ package tui import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" "github.com/ygelfand/plexctl/internal/ui" ) @@ -61,6 +62,30 @@ func (n *Navigator) Update(msg tea.Msg) (tea.Cmd, bool) { if overlay == nil { return nil, false } + if mouse, ok := msg.(tea.MouseMsg); ok { + if ui.RightClick(mouse) { + msg = ui.KeyPress("esc") + } else { + view := overlay.View() + width, height := lipgloss.Size(view) + rect := ui.Rect{X: max(0, (n.width-width)/2), Y: max(0, (n.height-height)/2), + Width: min(width, n.width), Height: min(height, n.height)} + local, inside := rect.Local(mouse) + if !inside { + return nil, true + } + msg = local + // Only declared action bars are clickable shortcuts. An option's + // title may itself contain brackets and must remain ordinary text. + if actions, ok := overlay.(interface{ ActionRows() int }); ok { + if local.Y >= height-2-actions.ActionRows() && local.Y < height-2 { + if key := ui.ShortcutAt(view, local); key != "" { + msg = ui.KeyPress(key) + } + } + } + } + } newModel, cmd := overlay.Update(msg) if newModel == nil { diff --git a/internal/tui/navigator_test.go b/internal/tui/navigator_test.go new file mode 100644 index 0000000..35fe8a7 --- /dev/null +++ b/internal/tui/navigator_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/ui" +) + +type mouseOverlay struct{ last tea.Msg } + +func (m *mouseOverlay) Init() tea.Cmd { return nil } +func (m *mouseOverlay) View() string { return "1234567890\n1234567890\n1234567890\n1234567890" } +func (m *mouseOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.last = msg; return m, nil } + +func TestOverlayCapturesAndTranslatesMouse(t *testing.T) { + n := NewNavigator(ui.PlexctlTheme) + n.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + o := &mouseOverlay{} + n.Push(o) + o.last = nil + for _, mouse := range []tea.MouseMsg{ + {X: 1, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}, + {X: 20, Y: 12, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}, + } { + if _, captured := n.Update(mouse); !captured || o.last != nil { + t.Fatal("background mouse event escaped modal bounds") + } + } + n.Update(tea.MouseMsg{X: 37, Y: 11, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if mouse, ok := o.last.(tea.MouseMsg); !ok || mouse.X != 2 || mouse.Y != 1 { + t.Fatalf("local mouse = %#v", o.last) + } + n.Update(tea.MouseMsg{X: 1, Y: 1, Button: tea.MouseButtonRight, Action: tea.MouseActionPress}) + if key, ok := o.last.(tea.KeyMsg); !ok || key.String() != "esc" { + t.Fatalf("right click = %#v", o.last) + } +} diff --git a/internal/tui/player/actions.go b/internal/tui/player/actions.go index f231e4e..0226f69 100644 --- a/internal/tui/player/actions.go +++ b/internal/tui/player/actions.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "strings" + "time" "github.com/LukeHagar/plexgo/models/components" tea "github.com/charmbracelet/bubbletea" @@ -13,8 +14,9 @@ import ( "github.com/ygelfand/plexctl/internal/ui" ) -func PlayMedia(metadata *components.Metadata, noReport bool, tctMode bool, startOffset int64) tea.Cmd { - if metadata == nil || len(metadata.Media) == 0 || len(metadata.Media[0].Part) == 0 { +func PlayMedia(metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64) tea.Cmd { + part := plex.PlayablePart(metadata) + if part == nil { return func() tea.Msg { return fmt.Errorf("no playable media found") } } @@ -24,7 +26,6 @@ func PlayMedia(metadata *components.Metadata, noReport bool, tctMode bool, start return func() tea.Msg { return fmt.Errorf("no active server") } } - part := metadata.Media[0].Part[0] separator := "?" if strings.Contains(part.Key, "?") { separator = "&" @@ -51,7 +52,7 @@ func PlayMedia(metadata *components.Metadata, noReport bool, tctMode bool, start } } - slog.Debug("Playing media", "url", playURL, "tct", tctMode, "offset", startOffset, "subtitles", len(subtitles)) + slog.Debug("Playing media", "output", output, "offset", startOffset, "subtitles", len(subtitles)) rk := "" if metadata.RatingKey != nil { rk = *metadata.RatingKey @@ -62,13 +63,19 @@ func PlayMedia(metadata *components.Metadata, noReport bool, tctMode bool, start title = "Trailer: " + title } - return GetPlayerManager().Play(playURL, title, rk, noReport, tctMode, startOffset, subtitles) + return GetPlayerManager().Play(playURL, title, rk, noReport, output, startOffset, subtitles) } // FetchAndPlay handles the full playback logic: fetch full metadata, check for resume, then play -func FetchAndPlay(ratingKey string, tctMode bool) tea.Cmd { +func FetchAndPlay(ratingKey string, output config.VideoOutput) tea.Cmd { return func() tea.Msg { - meta, err := plex.GetMetadata(context.Background(), ratingKey, true) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + meta, err := plex.GetMetadata(ctx, ratingKey, true) + if err != nil { + return err + } + meta, err = plex.ResolvePlayable(ctx, meta) if err != nil { return err } @@ -76,11 +83,11 @@ func FetchAndPlay(ratingKey string, tctMode bool) tea.Cmd { if meta.ViewOffset != nil && *meta.ViewOffset > 0 { return ui.ResumeChoiceMsg{ Metadata: meta, - TctMode: tctMode, + Output: output, } } - cmd := PlayMedia(meta, false, tctMode, 0) + cmd := PlayMedia(meta, false, output, 0) if cmd != nil { return cmd() } @@ -89,7 +96,7 @@ func FetchAndPlay(ratingKey string, tctMode bool) tea.Cmd { } // FetchAndPlayTrailer handles finding and playing the correct trailer for a media item -func FetchAndPlayTrailer(parentMeta *components.Metadata, tctMode bool) tea.Cmd { +func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOutput) tea.Cmd { return func() tea.Msg { targetKey := "" if parentMeta.PrimaryExtraKey != nil { @@ -120,7 +127,7 @@ func FetchAndPlayTrailer(parentMeta *components.Metadata, tctMode bool) tea.Cmd return err } - cmd := PlayMedia(meta, true, tctMode, 0) + cmd := PlayMedia(meta, true, output, 0) if cmd != nil { return cmd() } diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index 084ec6a..bb27702 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -76,9 +76,12 @@ func (pm *PlayerManager) Status() PlayerStatus { return pm.status } -func (pm *PlayerManager) Play(url, title, ratingKey string, noReport bool, tctMode bool, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { +func (pm *PlayerManager) Play(url, title, ratingKey string, noReport bool, output config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { + if output != config.VideoWindow { + return pm.playTerminal(url, title, ratingKey, noReport, output, startOffset, subtitles) + } return func() tea.Msg { - slog.Debug("PlayerManager.Play start", "title", title, "rk", ratingKey, "tct", tctMode, "offset", startOffset) + slog.Debug("PlayerManager.Play start", "title", title, "rk", ratingKey, "offset", startOffset) if err := pm.ensureMpv(); err != nil { slog.Error("PlayerManager: mpv check failed", "error", err) return err @@ -91,11 +94,7 @@ func (pm *PlayerManager) Play(url, title, ratingKey string, noReport bool, tctMo } if pm.conn != nil { - if pm.status.TctMode != tctMode { - slog.Debug("PlayerManager: Mode changed, restarting mpv", "old", pm.status.TctMode, "new", tctMode) - pm.conn.Call("quit") - pm.cleanup() - } else if pm.status.Key != "" && pm.status.Key != ratingKey { + if pm.status.Key != "" && pm.status.Key != ratingKey { slog.Debug("PlayerManager: different media, reporting progress first") pm.reportProgressWithKey() } @@ -123,27 +122,27 @@ func (pm *PlayerManager) Play(url, title, ratingKey string, noReport bool, tctMo } if pm.conn == nil { - if err := pm.spawnMpv(tctMode); err != nil { + if err := pm.spawnMpv(); err != nil { slog.Error("PlayerManager: spawn failed", "error", err) return err } } } + previous := pm.status pm.status = PlayerStatus{ Title: title, File: url, Key: ratingKey, NoReport: noReport, - TctMode: tctMode, Time: float64(startOffset) / 1000.0, } pm.status.Play() - startSec := float64(startOffset) / 1000.0 - slog.Debug("PlayerManager: sending loadfile to mpv", "url", url, "startSec", startSec) - - pm.conn.Call("loadfile", url, "replace", "-1", fmt.Sprintf("start=%.3f", startSec)) + if err := loadWindowMedia(pm.socketPath, url, startOffset); err != nil { + pm.status = previous + return err + } pm.conn.Call("set_property", "pause", false) pm.conn.Call("set_property", "force-media-title", title) pm.conn.Call("set_property", "user-data/plex-rating-key", ratingKey) @@ -247,7 +246,12 @@ func (pm *PlayerManager) PollUpdates() tea.Cmd { } func (pm *PlayerManager) sendUpdate() { - pm.updates <- PlayerStatusMsg{} + // Updates are notifications to read the latest status. Coalesce them while + // Bubble Tea is suspended for terminal playback so the monitor cannot block. + select { + case pm.updates <- PlayerStatusMsg{}: + default: + } } func (pm *PlayerManager) WaitForUpdates() tea.Cmd { @@ -286,23 +290,11 @@ func (pm *PlayerManager) socketExists() bool { return err == nil } -func (pm *PlayerManager) spawnMpv(tctMode bool) error { - args := []string{"--idle", "--no-resume-playback", fmt.Sprintf("--input-ipc-server=%s", pm.socketPath)} - if tctMode { - args = append(args, "-vo", "tct", "--really-quiet", "--vo-tct-buffering=frame") - if config.IsTUI { - args = append(args, "--vo-tct-height=40") - } - } else { - args = append(args, "--force-window=yes") - } +func (pm *PlayerManager) spawnMpv() error { + args := []string{"--idle", "--no-resume-playback", fmt.Sprintf("--input-ipc-server=%s", pm.socketPath), "--force-window=yes"} slog.Debug("PlayerManager: spawning mpv", "args", args) cmd := exec.Command("mpv", args...) - if tctMode { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start mpv: %w", err) @@ -487,27 +479,31 @@ func (pm *PlayerManager) cleanup() { func (pm *PlayerManager) reportProgressWithKey() { pm.sendUpdate() - if pm.status.NoReport || pm.status.Key == "" { + reportProgress(context.Background(), pm.status) +} + +func reportProgress(ctx context.Context, status PlayerStatus) { + if status.NoReport || status.Key == "" { return } - timePos := int64(pm.status.Time * 1000) - duration := int64(pm.status.Duration * 1000) + timePos := int64(status.Time * 1000) + duration := int64(status.Duration * 1000) client, err := plex.NewClient() if err != nil { return } - st := operations.State(pm.status.State) + st := status.State forNow := "PlexCTL" req := operations.ReportRequest{ State: &st, Time: &timePos, Duration: &duration, - Key: &pm.status.Key, - RatingKey: &pm.status.Key, + Key: &status.Key, + RatingKey: &status.Key, Device: &forNow, } - _, _ = client.SDK.Timeline.Report(context.Background(), req) + _, _ = client.SDK.Timeline.Report(ctx, req) } diff --git a/internal/tui/player/player_state.go b/internal/tui/player/player_state.go index 172da66..e30efb0 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -10,7 +10,6 @@ type PlayerStatus struct { Key string State operations.State NoReport bool - TctMode bool } type ExternalSubtitle struct { diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go new file mode 100644 index 0000000..448a7c9 --- /dev/null +++ b/internal/tui/player/terminal.go @@ -0,0 +1,415 @@ +package player + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand/v2" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/LukeHagar/plexgo/models/operations" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" + "golang.org/x/term" +) + +// terminalPlayback owns a foreground mpv process. Bubble Tea releases its input +// and renderer for the entire Run call, then restores the library view. +// Its status is private: IPC events and progress reporting run on the same loop. +type terminalPlayback struct { + mpv string + output config.VideoOutput + preference config.VideoOutput + status PlayerStatus + subtitles []ExternalSubtitle + stdin io.Reader + stdout io.Writer + report func(context.Context, PlayerStatus) +} + +var _ tea.ExecCommand = (*terminalPlayback)(nil) + +func (pm *PlayerManager) playTerminal(url, title, ratingKey string, noReport bool, preference config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { + return func() tea.Msg { + if !config.IsTUI { + termgraphics.Probe(os.Stdin, os.Stdout) + } + mpv, output, err := terminalOutput(context.Background(), preference, os.Getenv) + if err != nil { + return err + } + playback := &terminalPlayback{ + mpv: mpv, output: output, preference: preference, subtitles: subtitles, report: reportProgress, + status: PlayerStatus{ + Title: title, File: url, Key: ratingKey, NoReport: noReport, + Time: float64(startOffset) / 1000, + }, + } + // A separate socket prevents a later TUI from attaching to a foreground + // player whose terminal belongs to this session. + pm.StopPlayback()() + finished := func(err error) tea.Msg { + if err != nil { + return err + } + return PlayerStatusMsg{} + } + if config.IsTUI { + return tea.Exec(playback, finished)() + } + playback.SetStdin(os.Stdin) + playback.SetStdout(os.Stdout) + return finished(playback.Run()) + } +} + +func (p *terminalPlayback) SetStdin(r io.Reader) { p.stdin = r } +func (p *terminalPlayback) SetStdout(w io.Writer) { + if output, ok := w.(interface{ TerminalFile() *os.File }); ok { + w = output.TerminalFile() + } + p.stdout = w +} + +// mpv diagnostics can contain authenticated media URLs and corrupt graphics. +// Failures are returned to the TUI after it has reclaimed the terminal. +func (p *terminalPlayback) SetStderr(io.Writer) {} + +func (p *terminalPlayback) args(socket string) []string { + args := []string{ + "--idle=once", "--keep-open=no", "--no-resume-playback", + "--pause=no", "--loop-file=no", "--loop-playlist=no", + "--force-window=no", "--terminal=yes", "--input-terminal=yes", + "--input-default-bindings=yes", "--really-quiet", + "--vo=" + string(p.output), "--input-ipc-server=" + socket, + "--force-media-title=" + p.status.Title, + fmt.Sprintf("--start=%.3f", p.status.Time), + } + if p.output == config.VideoKitty { + // Escape-code transport also works over SSH; shared memory does not. + args = append(args, "--vo-kitty-use-shm=no") + } + return args +} + +func (p *terminalPlayback) Run() error { + for _, stream := range []any{p.stdin, p.stdout} { + if f, ok := stream.(interface{ Fd() uintptr }); ok && !term.IsTerminal(int(f.Fd())) { + return fmt.Errorf("terminal video requires an interactive terminal") + } + } + if input, ok := p.stdin.(*os.File); ok { + caps := termgraphics.Probe(input, p.stdout) + if p.preference != config.VideoWindow { + var err error + p.mpv, p.output, err = terminalOutput(context.Background(), p.preference, os.Getenv) + if err != nil { + return err + } + } + if caps.Responded && ((p.output == config.VideoKitty && !caps.Kitty) || (p.output == config.VideoSixel && !caps.Sixel)) { + name := caps.Name + if name == "" { + name = "the attached terminal" + } + return fmt.Errorf("%s does not advertise %s graphics; open plexctl in a supporting terminal or select Text", name, p.output) + } + } + dir, err := os.MkdirTemp("", "plexctl-mpv-") + if err != nil { + return fmt.Errorf("create terminal player socket directory: %w", err) + } + defer os.RemoveAll(dir) + socket := filepath.Join(dir, "ipc") + if runtime.GOOS == "windows" { + socket = `\\.\pipe\` + filepath.Base(dir) + } + + cmd := exec.Command(p.mpv, p.args(socket)...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = p.stdin, p.stdout, io.Discard + var keys <-chan string + if termgraphics.InTmux() && (p.output == config.VideoKitty || p.output == config.VideoSixel) { + restore, err := termgraphics.EnablePassthrough() + if err != nil { + return err + } + defer restore() + unzoom, err := termgraphics.ZoomPane() + if err != nil { + return err + } + defer unzoom() + imageID := (1 << 30) + rand.IntN(1<<20) + cmd.Stdout = &termgraphics.Writer{Output: p.stdout, ImageID: imageID} + defer func() { + if p.output == config.VideoKitty { + _, _ = io.WriteString(p.stdout, termgraphics.Wrap(termgraphics.DeleteImage(imageID))) + } else { + _, _ = io.WriteString(p.stdout, termgraphics.Wrap("\x1b[2J\x1b[H")) + } + }() + } + // Our wrapper handles actual tmux panes. Also remove stale tmux variables + // from direct Kitty windows so mpv cannot wrap their graphics by mistake. + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "TMUX=") && !strings.HasPrefix(entry, "TMUX_PANE=") { + cmd.Env = append(cmd.Env, entry) + } + } + if input, ok := p.stdin.(*os.File); ok && term.IsTerminal(int(input.Fd())) { + // Own input while mpv plays: this works with piped graphics in tmux + // and provides the same mouse controls in every terminal renderer. + cmd.Args = append(cmd.Args, "--input-terminal=no") + var stop func() + keys, stop, err = readPlaybackKeys(p.stdin) + if err != nil { + return err + } + defer stop() + _, _ = io.WriteString(p.stdout, "\x1b[?1000h\x1b[?1006h") + defer func() { _, _ = io.WriteString(p.stdout, "\x1b[?1000l\x1b[?1006l") }() + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start terminal player: %w", err) + } + done := make(chan struct{}) + var waitErr error + go func() { + waitErr = cmd.Wait() + close(done) + }() + waited := false + defer func() { + if !waited { + _ = cmd.Process.Kill() + <-done + } + }() + + conn, err := connectTerminal(socket, done) + if err != nil { + return err + } + defer conn.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events := make(chan terminalEvent, 64) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + readTerminalEvents(ctx, conn, events) + }() + defer func() { + cancel() + _ = conn.Close() + <-readerDone + }() + + for id, property := range []string{"time-pos", "pause", "duration"} { + if err := sendTerminalCommand(conn, 0, "observe_property", id+1, property); err != nil { + return err + } + } + // Request ID 1 lets us catch a rejected load even when mpv stays idle. + if err := sendTerminalCommand(conn, 1, "loadfile", p.status.File, "replace"); err != nil { + return err + } + + loaded := false + defer func() { + if loaded { + p.status.State = operations.StateStopped + p.reportStatus() + } + }() + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + keyTimer := time.NewTimer(time.Hour) + keyTimer.Stop() + defer keyTimer.Stop() + pendingKeys := "" + forwardKeys := func(flush bool) error { + var decoded []string + decoded, pendingKeys = playbackKeys(pendingKeys, flush) + for _, key := range decoded { + if err := sendTerminalCommand(conn, 0, "keypress", key); err != nil { + return err + } + } + if pendingKeys != "" { + keyTimer.Reset(50 * time.Millisecond) + } + return nil + } + for { + select { + case data, ok := <-keys: + if !ok { + keys = nil + continue + } + pendingKeys += data + keyTimer.Stop() + if err := forwardKeys(false); err != nil { + return err + } + case <-keyTimer.C: + if err := forwardKeys(true); err != nil { + return err + } + case ev, ok := <-events: + if !ok { + // IPC normally closes just before Wait returns on EOF or q. + select { + case <-done: + waited = true + return p.exitError(waitErr) + case <-time.After(2 * time.Second): + return fmt.Errorf("terminal player disconnected from IPC") + } + } + if ev.RequestID == 1 && ev.Error != "success" { + return fmt.Errorf("mpv rejected the media stream") + } + switch ev.Event { + case "file-loaded": + loaded = true + p.status.Play() + for _, sub := range p.subtitles { + if err := sendTerminalCommand(conn, 0, "sub-add", sub.URL, "auto", sub.Title, sub.Language); err != nil { + return err + } + } + p.reportStatus() + case "property-change": + oldState := p.status.State + p.applyProperty(ev) + if loaded && p.status.State != oldState { + p.reportStatus() + } + case "end-file": + if ev.Reason == "error" { + return fmt.Errorf("mpv could not play the stream using %s; check the server connection and terminal graphics support", p.output) + } + if ev.Reason == "eof" && p.status.Duration > 0 { + p.status.Time = p.status.Duration + } + case "shutdown": + // Continue draining properties until the process exits. + } + case <-ticker.C: + if loaded { + p.reportStatus() + } + } + } +} + +func (p *terminalPlayback) exitError(err error) error { + var exit *exec.ExitError + // mpv uses status 4 for a user interrupt (Ctrl+C). + if errors.As(err, &exit) && exit.ExitCode() == 4 { + return nil + } + if err != nil { + return fmt.Errorf("terminal playback with %s failed; check terminal support and 'mpv --vo=help': %w", p.output, err) + } + return nil +} + +func (p *terminalPlayback) reportStatus() { + if p.report == nil || p.status.NoReport || p.status.Key == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + p.report(ctx, p.status) +} + +type terminalEvent struct { + Event string `json:"event"` + ID int `json:"id"` + Data json.RawMessage `json:"data"` + Reason string `json:"reason"` + RequestID int `json:"request_id"` + Error string `json:"error"` +} + +func (p *terminalPlayback) applyProperty(ev terminalEvent) { + if len(ev.Data) == 0 || string(ev.Data) == "null" { + return + } + switch ev.ID { + case 1: + _ = json.Unmarshal(ev.Data, &p.status.Time) + case 2: + var paused bool + if json.Unmarshal(ev.Data, &paused) == nil { + if paused { + p.status.State = operations.StatePaused + } else { + p.status.Play() + } + } + case 3: + _ = json.Unmarshal(ev.Data, &p.status.Duration) + } +} + +func connectTerminal(socket string, done <-chan struct{}) (net.Conn, error) { + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + retry := time.NewTicker(50 * time.Millisecond) + defer retry.Stop() + for { + select { + case <-done: + return nil, fmt.Errorf("mpv exited before terminal playback started; check 'mpv --vo=help'") + case <-timeout.C: + return nil, fmt.Errorf("timed out connecting to the terminal player") + case <-retry.C: + if conn, err := dialTerminal(socket); err == nil { + return conn, nil + } + } + } +} + +func sendTerminalCommand(conn net.Conn, requestID int, args ...any) error { + if err := conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil { + return fmt.Errorf("set player IPC deadline: %w", err) + } + command := struct { + Command []any `json:"command"` + RequestID int `json:"request_id"` + }{args, requestID} + if err := json.NewEncoder(conn).Encode(command); err != nil { + return fmt.Errorf("send player IPC command: %w", err) + } + return nil +} + +func readTerminalEvents(ctx context.Context, conn net.Conn, events chan<- terminalEvent) { + defer close(events) + decoder := json.NewDecoder(conn) + for { + var ev terminalEvent + if err := decoder.Decode(&ev); err != nil { + return + } + select { + case events <- ev: + case <-ctx.Done(): + return + } + } +} diff --git a/internal/tui/player/terminal_integration_test.go b/internal/tui/player/terminal_integration_test.go new file mode 100644 index 0000000..b48ff27 --- /dev/null +++ b/internal/tui/player/terminal_integration_test.go @@ -0,0 +1,251 @@ +//go:build integration && !windows + +package player + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/LukeHagar/plexgo/models/operations" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" +) + +// Use this test binary as a small mpv IPC peer, without a display, Plex server, +// installed mpv, or network access. Run with go test -tags=integration. +func fakeTerminalMPV(t *testing.T, scenario string) string { + t.Helper() + t.Setenv("TMUX", "") + t.Setenv("TMUX_PANE", "") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + path := filepath.Join(dir, "mpv") + script := "#!/bin/sh\nexec \"$PLEXCTL_TEST_BINARY\" -test.run=^TestTerminalMPVHelper$ -- \"$@\"\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PLEXCTL_TEST_BINARY", binary) + t.Setenv("PLEXCTL_MPV_HELPER", scenario) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return path +} + +func TestTerminalPlayback(t *testing.T) { + tests := []struct { + name string + noReport bool + wantTime float64 + wantErr bool + }{ + {name: "eof", wantTime: 120}, + {name: "quit", wantTime: 31.5}, + {name: "trailer", noReport: true}, + {name: "startup-failure", wantErr: true}, + {name: "load-failure", wantErr: true}, + {name: "stream-failure", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var reports []PlayerStatus + p := &terminalPlayback{ + mpv: fakeTerminalMPV(t, tt.name), output: config.VideoKitty, + stdin: strings.NewReader(""), stdout: io.Discard, + status: PlayerStatus{Title: "A film -- with spaces", File: "https://plex.invalid/video?X-Plex-Token=secret", Key: "42", Time: 12.345, NoReport: tt.noReport}, + subtitles: []ExternalSubtitle{{URL: "https://plex.invalid/sub?token=secret", Title: "English CC", Language: "eng"}}, + report: func(ctx context.Context, status PlayerStatus) { + if _, ok := ctx.Deadline(); !ok { + t.Error("progress report has no deadline") + } + reports = append(reports, status) + }, + } + err := p.Run() + if (err != nil) != tt.wantErr { + t.Fatalf("Run error = %v, want error %v", err, tt.wantErr) + } + if err != nil && strings.Contains(err.Error(), "secret") { + t.Fatal("playback error exposed an authentication token") + } + if tt.wantErr { + return + } + if tt.noReport { + if len(reports) != 0 { + t.Fatalf("trailer sent %d progress reports", len(reports)) + } + return + } + if len(reports) < 3 { + t.Fatalf("missing playback reports: %+v", reports) + } + last := reports[len(reports)-1] + if last.State != operations.StateStopped || last.Time != tt.wantTime || last.Duration != 120 || last.Key != "42" { + t.Errorf("incorrect final progress: %+v", last) + } + if !slices.ContainsFunc(reports, func(s PlayerStatus) bool { return s.Paused() }) { + t.Error("pause was not reported") + } + }) + } +} + +type terminalTestModel struct { + command tea.Cmd + err error + done bool +} + +func (m *terminalTestModel) Init() tea.Cmd { return m.command } +func (m *terminalTestModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case error: + m.err, m.done = msg, true + return m, tea.Quit + case PlayerStatusMsg: + m.done = true + return m, tea.Quit + } + return m, nil +} +func (m *terminalTestModel) View() string { + if m.done { + return "library restored" + } + return "library before playback" +} + +func TestTerminalPlaybackRestoresTUI(t *testing.T) { + wasTUI := config.IsTUI + config.IsTUI = true + t.Cleanup(func() { config.IsTUI = wasTUI }) + for _, scenario := range []string{"tui", "startup-failure"} { + t.Run(scenario, func(t *testing.T) { + fakeTerminalMPV(t, scenario) + pm := &PlayerManager{updates: make(chan tea.Msg, 1)} + m := &terminalTestModel{command: pm.Play("https://plex.invalid/video", "Film", "", true, config.VideoKitty, 0, nil)} + var output bytes.Buffer + program := tea.NewProgram(m, tea.WithInput(strings.NewReader("")), tea.WithOutput(&output), tea.WithoutSignalHandler()) + if _, err := program.Run(); err != nil { + t.Fatal(err) + } + if !m.done || (m.err != nil) != (scenario == "startup-failure") { + t.Fatalf("playback did not return to TUI: done=%v error=%v", m.done, m.err) + } + view := output.String() + if !strings.Contains(view, "library restored") { + t.Fatalf("library not restored: %q", view) + } + if scenario == "startup-failure" { + return + } + start, end := strings.Index(view, "video-start"), strings.Index(view, "video-end") + if start < 0 || end < start || !strings.Contains(view[end:], "library restored") { + t.Fatalf("missing terminal handoff or restoration: %q", view) + } + if strings.Contains(view[start:end], "library") { + t.Fatal("TUI rendered over video") + } + }) + } +} + +func TestTerminalMPVHelper(t *testing.T) { + scenario := os.Getenv("PLEXCTL_MPV_HELPER") + if scenario == "" { + return + } + if slices.Contains(os.Args, "--vo=help") { + fmt.Println("Available video outputs:\n kitty graphics\n sixel graphics\n tct text") + os.Exit(0) + } + if scenario == "startup-failure" { + os.Exit(3) + } + if !slices.Contains(os.Args, "--idle=once") || !slices.Contains(os.Args, "--vo=kitty") || !slices.Contains(os.Args, "--vo-kitty-use-shm=no") { + os.Exit(10) + } + if scenario != "tui" && !slices.Contains(os.Args, "--start=12.345") { + os.Exit(11) + } + var socket string + for _, arg := range os.Args { + if value, ok := strings.CutPrefix(arg, "--input-ipc-server="); ok { + socket = value + } + } + listener, err := net.Listen("unix", socket) + if err != nil { + os.Exit(12) + } + defer listener.Close() + conn, err := listener.Accept() + if err != nil { + os.Exit(13) + } + defer conn.Close() + decoder := json.NewDecoder(conn) + encoder := json.NewEncoder(conn) + for { + var request struct { + Command []any `json:"command"` + RequestID int `json:"request_id"` + } + if decoder.Decode(&request) != nil { + os.Exit(14) + } + if request.Command[0] != "loadfile" { + continue + } + if scenario == "load-failure" { + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "invalid parameter"}) + _, _ = io.Copy(io.Discard, conn) + return + } + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "success"}) + if scenario == "stream-failure" { + _ = encoder.Encode(map[string]any{"event": "end-file", "reason": "error"}) + return + } + _ = encoder.Encode(map[string]any{"event": "file-loaded"}) + if scenario != "tui" { + if decoder.Decode(&request) != nil || len(request.Command) != 5 || request.Command[0] != "sub-add" || request.Command[3] != "English CC" || request.Command[4] != "eng" { + os.Exit(15) + } + } + break + } + for _, property := range []struct { + id int + data any + }{{3, 120}, {1, 31.5}, {2, true}, {2, false}} { + _ = encoder.Encode(map[string]any{"event": "property-change", "id": property.id, "data": property.data}) + } + if scenario == "tui" { + fmt.Println("video-start") + time.Sleep(100 * time.Millisecond) + fmt.Println("video-end") + } + reason := "eof" + if scenario == "quit" { + reason = "quit" + } + _ = encoder.Encode(map[string]any{"event": "end-file", "reason": reason}) + _ = encoder.Encode(map[string]any{"event": "shutdown"}) + // Avoid the testing harness printing PASS into the simulated video stream. + _ = conn.Close() + _ = listener.Close() + os.Exit(0) +} diff --git a/internal/tui/player/terminal_keys.go b/internal/tui/player/terminal_keys.go new file mode 100644 index 0000000..9e05e86 --- /dev/null +++ b/internal/tui/player/terminal_keys.go @@ -0,0 +1,154 @@ +package player + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "unicode/utf8" + + "github.com/muesli/cancelreader" + "golang.org/x/term" +) + +func readPlaybackKeys(input io.Reader) (<-chan string, func(), error) { + f, ok := input.(*os.File) + if !ok || !term.IsTerminal(int(f.Fd())) { + return nil, nil, fmt.Errorf("video playback requires terminal input") + } + state, err := term.MakeRaw(int(f.Fd())) + if err != nil { + return nil, nil, err + } + r, err := cancelreader.NewReader(f) + if err != nil { + _ = term.Restore(int(f.Fd()), state) + return nil, nil, err + } + keys := make(chan string, 16) + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + defer close(keys) + buf := make([]byte, 256) + for { + n, err := r.Read(buf) + if n > 0 { + select { + case keys <- string(buf[:n]): + case <-stop: + return + } + } + if err != nil { + return + } + } + }() + return keys, func() { + close(stop) + r.Cancel() + <-done + _ = r.Close() + _ = term.Restore(int(f.Fd()), state) + }, nil +} + +var escapeKeys = map[string]string{ + "\x1b[A": "UP", "\x1b[B": "DOWN", "\x1b[C": "RIGHT", "\x1b[D": "LEFT", + "\x1bOA": "UP", "\x1bOB": "DOWN", "\x1bOC": "RIGHT", "\x1bOD": "LEFT", + "\x1b[H": "HOME", "\x1b[F": "END", "\x1b[5~": "PGUP", "\x1b[6~": "PGDWN", + "\x1b[3~": "DEL", "\x1b[2~": "INS", +} + +// playbackKeys preserves incomplete escape/UTF-8 sequences between pipe reads. +func playbackKeys(input string, flush bool) (keys []string, pending string) { + for input != "" { + if strings.HasPrefix(input, "\x1b[<") { + end := strings.IndexAny(input[3:], "Mm") + if end < 0 { + if !flush && len(input) <= 64 { + return keys, input + } + return keys, "" // Drop malformed reports without sending their digits to mpv. + } + end += 3 + if key := playbackMouseKey(input[3:end], input[end] == 'M'); key != "" { + keys = append(keys, key) + } + input = input[end+1:] + continue + } + matched, partial := false, false + if input[0] == '\x1b' { + for sequence, key := range escapeKeys { + if strings.HasPrefix(input, sequence) { + keys = append(keys, key) + input = input[len(sequence):] + matched = true + break + } + partial = partial || strings.HasPrefix(sequence, input) + } + if matched { + continue + } + if partial && !flush { + return keys, input + } + } + if !utf8.FullRuneInString(input) && !flush { + return keys, input + } + r, size := utf8.DecodeRuneInString(input) + input = input[size:] + switch r { + case ' ': + keys = append(keys, "SPACE") + case '\r', '\n': + keys = append(keys, "ENTER") + case '\t': + keys = append(keys, "TAB") + case '\x1b': + keys = append(keys, "ESC") + case '\x03': + keys = append(keys, "q") + case '\x7f', '\b': + keys = append(keys, "BS") + default: + if r >= 32 && r != utf8.RuneError { + keys = append(keys, string(r)) + } + } + } + return keys, "" +} + +func playbackMouseKey(report string, pressed bool) string { + fields := strings.Split(report, ";") + if !pressed || len(fields) != 3 { + return "" + } + button, err := strconv.Atoi(fields[0]) + x, xErr := strconv.Atoi(fields[1]) + y, yErr := strconv.Atoi(fields[2]) + if err != nil || xErr != nil || yErr != nil || x <= 0 || y <= 0 || button < 0 || button&32 != 0 || button >= 128 { + return "" + } + if button&64 != 0 { + switch button & 3 { + case 0, 3: + return "RIGHT" + case 1, 2: + return "LEFT" + } + } + switch button & 3 { + case 0: + return "SPACE" + case 2: + return "q" + } + return "" +} diff --git a/internal/tui/player/terminal_keys_test.go b/internal/tui/player/terminal_keys_test.go new file mode 100644 index 0000000..3b1bd94 --- /dev/null +++ b/internal/tui/player/terminal_keys_test.go @@ -0,0 +1,42 @@ +package player + +import ( + "slices" + "testing" +) + +func TestPlaybackKeys(t *testing.T) { + keys, pending := playbackKeys(" \x1b[Cq\x1b[", false) + if !slices.Equal(keys, []string{"SPACE", "RIGHT", "q"}) || pending != "\x1b[" { + t.Fatalf("keys=%v pending=%q", keys, pending) + } + keys, pending = playbackKeys(pending+"D\x03", false) + if !slices.Equal(keys, []string{"LEFT", "q"}) || pending != "" { + t.Fatalf("split arrow: %v, %q", keys, pending) + } + keys, pending = playbackKeys("\x1b", true) + if !slices.Equal(keys, []string{"ESC"}) || pending != "" { + t.Fatalf("escape timeout: %v, %q", keys, pending) + } +} + +func TestPlaybackMouseReports(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"\x1b[<0;20;8M", "SPACE"}, + {"\x1b[<0;20;8m", ""}, + {"\x1b[<32;20;8M", ""}, + {"\x1b[<64;20;8M", "RIGHT"}, + {"\x1b[<65;20;8M", "LEFT"}, + {"\x1b[<2;20;8M", "q"}, + {"\x1b[<0;bad;8M", ""}, + } { + for split := 1; split < len(tc.input); split++ { + first, pending := playbackKeys(tc.input[:split], false) + last, pending := playbackKeys(pending+tc.input[split:], false) + keys := append(first, last...) + if pending != "" || (tc.want == "" && len(keys) != 0) || (tc.want != "" && !slices.Equal(keys, []string{tc.want})) { + t.Fatalf("report %q split %d: keys=%v pending=%q", tc.input, split, keys, pending) + } + } + } +} diff --git a/internal/tui/player/terminal_unix.go b/internal/tui/player/terminal_unix.go new file mode 100644 index 0000000..a645082 --- /dev/null +++ b/internal/tui/player/terminal_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package player + +import ( + "net" + "time" +) + +func dialTerminal(socket string) (net.Conn, error) { + return net.DialTimeout("unix", socket, 100*time.Millisecond) +} diff --git a/internal/tui/player/terminal_windows.go b/internal/tui/player/terminal_windows.go new file mode 100644 index 0000000..62d7769 --- /dev/null +++ b/internal/tui/player/terminal_windows.go @@ -0,0 +1,12 @@ +package player + +import ( + "net" + "time" + + "gopkg.in/natefinch/npipe.v2" +) + +func dialTerminal(socket string) (net.Conn, error) { + return npipe.DialTimeout(socket, 100*time.Millisecond) +} diff --git a/internal/tui/player/video_output.go b/internal/tui/player/video_output.go new file mode 100644 index 0000000..fd3e815 --- /dev/null +++ b/internal/tui/player/video_output.go @@ -0,0 +1,75 @@ +package player + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +func terminalOutput(ctx context.Context, preference config.VideoOutput, getenv func(string) string) (string, config.VideoOutput, error) { + if _, err := config.ParseTerminalVideo(string(preference)); err != nil { + return "", config.VideoWindow, err + } + mpv, err := exec.LookPath("mpv") + if err != nil { + return "", config.VideoWindow, fmt.Errorf("mpv command not found; please install mpv: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + help, err := exec.CommandContext(ctx, mpv, "--no-config", "--vo=help").Output() + if err != nil { + return "", config.VideoWindow, fmt.Errorf("could not list mpv video outputs: %w", err) + } + output, err := selectTerminalOutput(preference, string(help), getenv, termgraphics.Current()) + return mpv, output, err +} + +func selectTerminalOutput(preference config.VideoOutput, help string, getenv func(string) string, caps termgraphics.Capabilities) (config.VideoOutput, error) { + available := make(map[config.VideoOutput]bool) + for line := range strings.SplitSeq(help, "\n") { + if fields := strings.Fields(line); len(fields) > 0 { + available[config.VideoOutput(fields[0])] = true + } + } + if preference != config.VideoAuto { + if !available[preference] { + return config.VideoWindow, fmt.Errorf("mpv does not provide the %s video output; check 'mpv --vo=help' or select another Terminal Video renderer in Settings", preference) + } + return preference, nil + } + + term := strings.ToLower(getenv("TERM")) + program := strings.ToLower(getenv("TERM_PROGRAM")) + var candidates []config.VideoOutput + if caps.Kitty { + candidates = append(candidates, config.VideoKitty) + } + if caps.Sixel { + candidates = append(candidates, config.VideoSixel) + } + // Multiplexers can hide capabilities or require passthrough configuration. + // Do not emit graphics automatically based on the outer terminal's env vars. + multiplexer := getenv("TMUX") != "" || getenv("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") + if !multiplexer && !caps.Responded { + switch { + case strings.Contains(term, "kitty"), getenv("KITTY_WINDOW_ID") != "", program == "kitty", program == "ghostty", term == "xterm-ghostty": + candidates = append(candidates, config.VideoKitty) + case getenv("KONSOLE_VERSION") != "": + candidates = append(candidates, config.VideoKitty, config.VideoSixel) + case strings.Contains(term, "sixel"), strings.HasPrefix(term, "foot"), strings.HasPrefix(term, "mlterm"), program == "wezterm", program == "contour", program == "mintty": + candidates = append(candidates, config.VideoSixel) + } + } + candidates = append(candidates, config.VideoTCT) + for _, output := range candidates { + if available[output] { + return output, nil + } + } + return config.VideoWindow, fmt.Errorf("no compatible terminal video output found; check 'mpv --vo=help' and select kitty, sixel, or tct in Settings") +} diff --git a/internal/tui/player/video_output_test.go b/internal/tui/player/video_output_test.go new file mode 100644 index 0000000..d112f47 --- /dev/null +++ b/internal/tui/player/video_output_test.go @@ -0,0 +1,54 @@ +package player + +import ( + "testing" + + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +func TestSelectTerminalOutput(t *testing.T) { + t.Parallel() + const all = "Available video outputs:\n kitty Kitty graphics\n sixel Sixel graphics\n tct true-color terminals\n" + tests := []struct { + name string + preference config.VideoOutput + help string + env map[string]string + caps termgraphics.Capabilities + want config.VideoOutput + wantErr bool + }{ + {name: "kitty", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "xterm-kitty"}, want: config.VideoKitty}, + {name: "kitty over ssh", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "xterm-kitty", "SSH_CONNECTION": "remote"}, want: config.VideoKitty}, + {name: "ghostty", preference: config.VideoAuto, help: all, env: map[string]string{"TERM_PROGRAM": "ghostty"}, want: config.VideoKitty}, + {name: "sixel", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "foot"}, want: config.VideoSixel}, + {name: "wezterm", preference: config.VideoAuto, help: all, env: map[string]string{"TERM_PROGRAM": "WezTerm"}, want: config.VideoSixel}, + {name: "konsole without kitty driver", preference: config.VideoAuto, help: "sixel graphics\ntct text", env: map[string]string{"KONSOLE_VERSION": "250000"}, want: config.VideoSixel}, + {name: "unknown terminal", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "xterm-256color"}, want: config.VideoTCT}, + {name: "missing graphics driver", preference: config.VideoAuto, help: "sixel graphics\ntct text", env: map[string]string{"TERM": "xterm-kitty"}, want: config.VideoTCT}, + {name: "tmux hides outer kitty", preference: config.VideoAuto, help: all, env: map[string]string{"TMUX": "socket", "KITTY_WINDOW_ID": "1"}, want: config.VideoTCT}, + {name: "probed kitty through tmux without tct", preference: config.VideoAuto, help: "kitty graphics", env: map[string]string{"TMUX": "socket"}, caps: termgraphics.Capabilities{Responded: true, Kitty: true}, want: config.VideoKitty}, + {name: "probed sixel through tmux", preference: config.VideoAuto, help: all, env: map[string]string{"TMUX": "socket"}, caps: termgraphics.Capabilities{Responded: true, Sixel: true}, want: config.VideoSixel}, + {name: "probe overrides stale kitty environment", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "xterm-kitty"}, caps: termgraphics.Capabilities{Responded: true, Name: "VTE(7600)"}, want: config.VideoTCT}, + {name: "screen hides outer kitty", preference: config.VideoAuto, help: all, env: map[string]string{"TERM": "screen-256color", "KITTY_WINDOW_ID": "1"}, want: config.VideoTCT}, + {name: "explicit sixel", preference: config.VideoSixel, help: all, want: config.VideoSixel}, + {name: "explicit kitty in tmux", preference: config.VideoKitty, help: all, env: map[string]string{"TMUX": "socket"}, want: config.VideoKitty}, + {name: "explicit tct", preference: config.VideoTCT, help: all, env: map[string]string{"TERM": "xterm-kitty"}, want: config.VideoTCT}, + {name: "explicit missing renderer", preference: config.VideoKitty, help: "tct text", wantErr: true}, + {name: "do not match descriptions", preference: config.VideoKitty, help: "gpu kitty sixel tct", wantErr: true}, + {name: "no compatible renderer", preference: config.VideoAuto, help: "gpu window\nkitty graphics", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := selectTerminalOutput(tt.preference, tt.help, func(key string) string { return tt.env[key] }, tt.caps) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, want error %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("output = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/tui/player/window_load.go b/internal/tui/player/window_load.go new file mode 100644 index 0000000..2cb78e4 --- /dev/null +++ b/internal/tui/player/window_load.go @@ -0,0 +1,48 @@ +package player + +import ( + "encoding/json" + "fmt" + "time" +) + +// loadWindowMedia uses named IPC arguments: mpv 0.38 inserted an argument before +// options in loadfile's positional form. Named arguments work on either side of +// that change and preserve per-file resume offsets without version guessing. +func loadWindowMedia(socket, url string, startOffset int64) error { + conn, err := dialTerminal(socket) + if err != nil { + return fmt.Errorf("connect to mpv for playback: %w", err) + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return err + } + request := map[string]any{ + "request_id": 1, + "command": map[string]any{ + "name": "loadfile", "url": url, "flags": "replace", + "options": map[string]string{"start": fmt.Sprintf("%.3f", float64(startOffset)/1000)}, + }, + } + if err := json.NewEncoder(conn).Encode(request); err != nil { + return fmt.Errorf("send playback command: %w", err) + } + decoder := json.NewDecoder(conn) + for { + var response struct { + RequestID int `json:"request_id"` + Error string `json:"error"` + } + if err := decoder.Decode(&response); err != nil { + return fmt.Errorf("read playback response: %w", err) + } + if response.RequestID != 1 { + continue + } + if response.Error != "success" { + return fmt.Errorf("mpv could not start playback: %s", response.Error) + } + return nil + } +} diff --git a/internal/tui/player/window_load_test.go b/internal/tui/player/window_load_test.go new file mode 100644 index 0000000..19eb9d9 --- /dev/null +++ b/internal/tui/player/window_load_test.go @@ -0,0 +1,61 @@ +//go:build !windows + +package player + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "testing" +) + +func TestWindowLoadNamedArguments(t *testing.T) { + for _, result := range []string{"success", "invalid parameter"} { + t.Run(result, func(t *testing.T) { + dir, err := os.MkdirTemp("", "plexctl-load-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "ipc") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + var request struct { + Command map[string]any `json:"command"` + } + if err := json.NewDecoder(conn).Decode(&request); err != nil { + done <- err + return + } + cmd := request.Command + options, ok := cmd["options"].(map[string]any) + if !ok || cmd["name"] != "loadfile" || cmd["flags"] != "replace" || cmd["url"] != "https://plex.invalid/music.flac" || options["start"] != "12.345" { + done <- fmt.Errorf("incorrect named loadfile request: %+v", cmd) + return + } + encoder := json.NewEncoder(conn) + _ = encoder.Encode(map[string]any{"event": "start-file"}) + done <- encoder.Encode(map[string]any{"request_id": 1, "error": result}) + }() + if err := loadWindowMedia(path, "https://plex.invalid/music.flac", 12345); (err != nil) != (result != "success") { + t.Errorf("loadWindowMedia returned %v for %s", err, result) + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 890f6a8..26d4770 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -14,11 +14,13 @@ import ( tint "github.com/lrstanley/bubbletint" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/tui/player" "github.com/ygelfand/plexctl/internal/tui/view" "github.com/ygelfand/plexctl/internal/tui/view/detail" tuiconfig "github.com/ygelfand/plexctl/internal/tui/widget/config" "github.com/ygelfand/plexctl/internal/tui/widget/help" + "github.com/ygelfand/plexctl/internal/tui/widget/playback" "github.com/ygelfand/plexctl/internal/tui/widget/resume" tuisearch "github.com/ygelfand/plexctl/internal/tui/widget/search" "github.com/ygelfand/plexctl/internal/tui/widget/settings" @@ -183,12 +185,15 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if navCmd, captured := c.navigator.Update(msg); captured { return c, navCmd } + if mouse, ok := msg.(tea.MouseMsg); ok { + return c.handleMouse(mouse) + } switch msg := msg.(type) { case ui.RequestPlayMsg: - return c, player.FetchAndPlay(msg.RatingKey, msg.TctMode) + return c, player.FetchAndPlay(msg.RatingKey, msg.Output) case ui.ResumeChoiceMsg: - return c, c.navigator.Push(resume.NewResumeOverlayModel(msg.Metadata, msg.TctMode, c.theme)) + return c, c.navigator.Push(resume.NewResumeOverlayModel(msg.Metadata, msg.Output, c.theme)) case ui.SelectMediaMsg: return c, c.handleSelectMedia(msg) case ui.JumpToDetailMsg: @@ -237,15 +242,21 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.navigator.Push(tuiconfig.NewConfigOverlayModel(c.data.Libraries)) case "h": return c, c.tabManager.SetActive(0) - case "p", "ctrl+p": - tctMode := msg.String() == "ctrl+p" + case "p", "ctrl+p", "v": + output := config.VideoWindow + if msg.String() == "ctrl+p" { + output = config.Get().TerminalVideo + } if provider, ok := c.tabManager.ActiveModel().(ui.PlayableProvider); ok { meta := provider.GetSelectedMetadata() if meta != nil && meta.RatingKey != nil { + if msg.String() == "v" { + return c, c.navigator.Push(playback.New(*meta.RatingKey, meta.Title, c.theme)) + } return c, func() tea.Msg { return ui.RequestPlayMsg{ RatingKey: *meta.RatingKey, - TctMode: tctMode, + Output: output, } } } @@ -468,12 +479,17 @@ func (c *Controller) fullReload() tea.Cmd { func (c *Controller) showHelp() tea.Cmd { keys := []ui.HelpKey{ + {Key: "click", Desc: "Open posters, rows, navigation, and actions"}, + {Key: "wheel", Desc: "Scroll under pointer; Shift+wheel browses a poster row"}, + {Key: "right click", Desc: "Back / close dialog (Shift bypasses mouse capture)"}, {Key: "tab", Desc: "Switch Library"}, {Key: "h", Desc: "Home"}, {Key: "ctrl+s", Desc: "Global Settings"}, {Key: "ctrl+l", Desc: "Libraries"}, {Key: "u", Desc: "Switch User"}, {Key: "p", Desc: "Play Selected"}, + {Key: "ctrl+p", Desc: "Play in Terminal"}, + {Key: "v", Desc: "Terminal Playback (Kitty / Sixel / Text)"}, {Key: "x", Desc: "Stop Playback"}, {Key: "q", Desc: "Quit"}, {Key: "?", Desc: "Help"}, @@ -493,7 +509,7 @@ func (c *Controller) showHelp() tea.Cmd { func (c *Controller) View() string { base := c.renderBaseView() base = c.navigator.Render(base) - return c.alert.Render(base) + return artwork.Frame(c.alert.Render(base)) } func (c *Controller) renderBaseView() string { @@ -516,7 +532,7 @@ func (c *Controller) renderBaseView() string { Border(lipgloss.RoundedBorder(), true). BorderForeground(c.theme.BrightBlack()). Padding(0, 1). - Width(layout.MainAreaContentWidth()). + Width(layout.MainAreaContentWidth() + 2). Height(contentHeight) content := "" @@ -524,6 +540,7 @@ func (c *Controller) renderBaseView() string { content = model.View() } + content = lipgloss.NewStyle().MaxWidth(layout.InnerWidth()).MaxHeight(contentHeight).Render(content) body := windowStyle.Render(content) if c.playerStatus.Active() { @@ -538,11 +555,72 @@ func (c *Controller) renderBaseView() string { Background(c.theme.BrightBlack()). Foreground(c.theme.White()). Padding(0, 1). - Render(" q: quit | tab: switch lib | h: home | ctrl+s: settings | ctrl+l: libs | u: user | p: play | x: stop ") + Render(c.footerText()) return lipgloss.JoinVertical(lipgloss.Left, mainArea, footer) } +func (c *Controller) footerText() string { + text := "[p] Play | [v] Video | [V] View | [/] Search | [?] Help | [q] Quit" + if c.playerStatus.Active() { + text = "[p] Play | [v] Video | [x] Stop | [/] Search | [?] Help | [q] Quit" + } + return ui.Ellipsis(text, max(1, ui.GetLayout().TotalWidth()-2)) +} + +func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { + layout := ui.GetLayout() + if ui.RightClick(mouse) { + return c.Update(ui.KeyPress("esc")) + } + if mouse.X < ui.SidebarWidth && mouse.Y < layout.TotalHeight()-1 { + if delta := ui.WheelDelta(mouse); delta != 0 { + c.tabManager.navbar.Scroll(delta * 3) + } else if ui.LeftClick(mouse) { + if index := c.tabManager.navbar.ItemAt(mouse); index >= 0 { + return c, c.tabManager.SetActive(index) + } + if key := c.tabManager.navbar.ActionAt(mouse); key != "" { + return c.Update(ui.KeyPress(key)) + } + } + return c, nil + } + if mouse.Y == layout.TotalHeight()-1 { + local := mouse + local.X-- // Footer padding. + local.Y = 0 + if key := ui.ShortcutAt(c.footerText(), local); key != "" { + return c.Update(ui.KeyPress(key)) + } + return c, nil + } + rect := ui.Rect{X: ui.SidebarTotalWidth + 2, Y: 1, Width: layout.InnerWidth(), Height: layout.ContentHeight()} + local, inside := rect.Local(mouse) + if !inside { + if c.playerStatus.Active() && ui.LeftClick(mouse) && mouse.X > ui.SidebarTotalWidth && + mouse.Y >= rect.Height+2 && mouse.Y < layout.TotalHeight()-1 { + return c, c.player.TogglePause() + } + return c, nil + } + if model := c.tabManager.ActiveModel(); model != nil { + // Detail action bars appear at the bottom of their own view. + view := model.View() + if actions, ok := model.(interface{ ActionRows() int }); ok { + if local.Y >= lipgloss.Height(view)-actions.ActionRows() { + if key := ui.ShortcutAt(view, local); key != "" { + return c.Update(ui.KeyPress(key)) + } + } + } + var cmd tea.Cmd + c.tabManager.tabModels[c.tabManager.activeTabIdx], cmd = model.Update(local) + return c, cmd + } + return c, nil +} + func (c *Controller) renderPlayer() string { layout := ui.GetLayout() width := layout.MainAreaContentWidth() diff --git a/internal/tui/view/detail/base.go b/internal/tui/view/detail/base.go index 64c2062..73770bb 100644 --- a/internal/tui/view/detail/base.go +++ b/internal/tui/view/detail/base.go @@ -75,7 +75,7 @@ func (b *DetailBase) Update(msg tea.Msg) tea.Cmd { func (b *DetailBase) UpdateLayout() { layout := ui.GetLayout() - rightWidth := layout.DetailRightColumnWidth(b.Poster != "") + rightWidth := layout.DetailRightColumnWidth(b.showPoster()) b.SynopsisVP.Width = rightWidth // Default height for synopsis, can be overridden by specific views @@ -106,7 +106,7 @@ func (b *DetailBase) RenderHeader(rightWidth int) string { } func (b *DetailBase) RenderPosterAndInfo(infoSection string) string { - if b.Poster != "" { + if b.showPoster() { posterBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(b.Theme.BrightBlack()). @@ -119,3 +119,8 @@ func (b *DetailBase) RenderPosterAndInfo(infoSection string) string { } return infoSection } + +func (b *DetailBase) showPoster() bool { + layout := ui.GetLayout() + return b.Poster != "" && layout.InnerWidth() >= 70 && layout.ContentHeight() >= 30 +} diff --git a/internal/tui/view/detail/detail_shared.go b/internal/tui/view/detail/detail_shared.go index 718025e..fd99ebd 100644 --- a/internal/tui/view/detail/detail_shared.go +++ b/internal/tui/view/detail/detail_shared.go @@ -18,8 +18,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" - gopixels "github.com/saran13raj/go-pixels" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) @@ -68,7 +68,7 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { // Check long-term cache for rendered string if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, targetWidth); ok { + if cached, ok := artwork.Cached(rk, targetWidth); ok { return posterDataMsg(cached) } } @@ -88,18 +88,13 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { return nil } - imgStr, err := gopixels.FromImageStream(img, targetWidth, 0, "halfcell", true) + imgStr, err := artwork.Render(rk, img, targetWidth, 0) if err != nil { slog.Error("fetchPoster render failed", "error", err) return nil } slog.Debug("fetchPoster render complete", "duration", time.Since(start)) - // Save to long-term cache - if rk != "" { - plex.SetCachedPoster(rk, targetWidth, imgStr) - } - return posterDataMsg(imgStr) } } diff --git a/internal/tui/view/detail/episode_detail.go b/internal/tui/view/detail/episode_detail.go index 84d658b..5dd3a60 100644 --- a/internal/tui/view/detail/episode_detail.go +++ b/internal/tui/view/detail/episode_detail.go @@ -141,5 +141,5 @@ func (v *EpisodeDetailView) View() string { mainLayout := v.RenderPosterAndInfo(infoSection) return lipgloss.NewStyle().Padding(1, 2).Render(mainLayout) + - "\n\n " + dimStyle.Render("[p] Play | [s] Season | [S] Show | [esc] Back | [↑/↓] Scroll Synopsis") + "\n\n " + dimStyle.Render("[p] Play | [v] Terminal | [s] Season | [S] Show | [esc] Back | [↑/↓] Scroll Synopsis") } diff --git a/internal/tui/view/detail/movie_detail.go b/internal/tui/view/detail/movie_detail.go index c774280..16633a7 100644 --- a/internal/tui/view/detail/movie_detail.go +++ b/internal/tui/view/detail/movie_detail.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/player" "github.com/ygelfand/plexctl/internal/ui" @@ -75,7 +76,7 @@ func (v *MovieDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (v *MovieDetailView) playTrailer() tea.Cmd { - return player.FetchAndPlayTrailer(v.Metadata, false) + return player.FetchAndPlayTrailer(v.Metadata, config.VideoWindow) } func (v *MovieDetailView) hasTrailer() bool { @@ -193,7 +194,7 @@ func (v *MovieDetailView) View() string { mainLayout := v.RenderPosterAndInfo(infoSection) - footer := "[p] Play " + footer := "[p] Play | [v] Terminal " if v.hasTrailer() { footer += "| [t] Trailer " } diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go new file mode 100644 index 0000000..8c1a6b6 --- /dev/null +++ b/internal/tui/view/detail/music_detail.go @@ -0,0 +1,187 @@ +package detail + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/ui" +) + +// MusicDetailView navigates artist -> album -> track while retaining each +// parent's selection and scroll position when returning from a child. +type MusicDetailView struct { + DetailBase + items list.Model + listRect ui.Rect + selected *MusicDetailView +} + +func NewMusicDetailView(ratingKey string, theme tint.Tint) *MusicDetailView { + return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme), items: createBaseDetailList()} +} + +func (v *MusicDetailView) Init() tea.Cmd { return v.fetchData } + +func (v *MusicDetailView) Refresh() tea.Cmd { + if v.selected != nil { + return v.selected.Refresh() + } + v.Loading = true + return v.fetchData +} + +func (v *MusicDetailView) fetchData() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + meta, err := plex.GetMetadata(ctx, v.RatingKey, true) + if err != nil { + return err + } + var children []components.Metadata + if meta.Type == "artist" || meta.Type == "album" { + children, err = plex.GetChildren(ctx, v.RatingKey) + if err != nil { + return err + } + } + return detailDataMsg{metadata: meta, children: children} +} + +func (v *MusicDetailView) GetSelectedMetadata() *components.Metadata { + if v.selected != nil { + return v.selected.GetSelectedMetadata() + } + if item, ok := v.items.SelectedItem().(musicItem); ok { + return &item.metadata + } + return v.Metadata +} + +func (v *MusicDetailView) IsAtRoot() bool { return v.selected == nil } + +func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if v.selected != nil { + if _, back := msg.(BackMsg); back && v.selected.IsAtRoot() { + v.selected = nil + return v, nil + } + _, cmd := v.selected.Update(msg) + return v, cmd + } + if mouse, ok := msg.(tea.MouseMsg); ok { + if local, inside := v.listRect.Local(mouse); inside { + if ui.ListMouse(&v.items, local) { + return v.Update(ui.KeyPress("enter")) + } + return v, nil + } + } + switch msg := msg.(type) { + case detailDataMsg: + items := make([]list.Item, 0, len(msg.children)) + for _, child := range msg.children { + items = append(items, musicItem{metadataItem{metadata: child}}) + } + v.items.SetItems(items) + v.items.Title = "Tracks" + if msg.metadata != nil && msg.metadata.Type == "artist" { + v.items.Title = "Albums" + } + case tea.KeyMsg: + switch msg.String() { + case "enter": + if item, ok := v.items.SelectedItem().(musicItem); ok && item.metadata.RatingKey != nil { + v.selected = NewMusicDetailView(*item.metadata.RatingKey, v.Theme) + return v, v.selected.Init() + } + case "esc", "backspace": + return v, func() tea.Msg { return BackMsg{} } + } + } + baseCmd := v.DetailBase.Update(msg) + var listCmd tea.Cmd + v.items, listCmd = v.items.Update(msg) + return v, tea.Batch(baseCmd, listCmd) +} + +func (v *MusicDetailView) HelpKeys() []ui.HelpKey { + return []ui.HelpKey{ + {Key: "enter", Desc: "Open Album / Track Details"}, + {Key: "p", Desc: "Play Selected Track (First Track of an Album)"}, + {Key: "↑/↓", Desc: "Select Album / Track"}, + {Key: "esc", Desc: "Back"}, + } +} + +func (v *MusicDetailView) View() string { + if v.selected != nil { + return v.selected.View() + } + if v.Loading { + return "\n Loading music..." + } + if v.Err != nil { + return "\n " + v.Err.Error() + "\n\n [esc] Back" + } + if v.Metadata == nil { + return "\n No music metadata available\n\n [esc] Back" + } + width := max(1, ui.GetLayout().InnerWidth()-4) + infoWidth := width + if v.Metadata.Type == "track" { + infoWidth = v.SynopsisVP.Width + } + info := musicItem{metadataItem{metadata: *v.Metadata}}.Description() + header := lipgloss.JoinVertical(lipgloss.Left, + v.RenderHeader(infoWidth), + lipgloss.NewStyle().Width(infoWidth).Foreground(v.Theme.BrightCyan()).Render(info), + ) + v.listRect = ui.Rect{} + footer := "[p] Play | [esc] Back" + if v.Metadata.Type == "track" { + header += "\n\n" + renderBadges(v.Metadata, v.Theme) + header = v.RenderPosterAndInfo(header) + } else { + v.listRect = ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} + v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-4) + v.items.SetSize(width, v.listRect.Height) + header += "\n\n" + v.items.View() + footer = "[enter] Open | [p] Play | [esc] Back" + } + return lipgloss.NewStyle().Padding(1, 2).Render(header) + "\n\n " + + lipgloss.NewStyle().Foreground(v.Theme.BrightBlack()).Render(footer) +} + +type musicItem struct{ metadataItem } + +func (i musicItem) Title() string { + if i.metadata.Type == "track" && i.metadata.Index != nil { + return fmt.Sprintf("%02d. %s", *i.metadata.Index, i.metadata.Title) + } + return i.metadata.Title +} + +func (i musicItem) Description() string { + var parts []string + if i.metadata.GrandparentTitle != nil { + parts = append(parts, *i.metadata.GrandparentTitle) + } + if i.metadata.ParentTitle != nil { + parts = append(parts, *i.metadata.ParentTitle) + } + if i.metadata.Type == "track" && i.metadata.Duration != nil { + seconds := *i.metadata.Duration / 1000 + parts = append(parts, fmt.Sprintf("%d:%02d", seconds/60, seconds%60)) + } else if i.metadata.Year != nil { + parts = append(parts, fmt.Sprint(*i.metadata.Year)) + } + return strings.Join(parts, " · ") +} diff --git a/internal/tui/view/detail/music_detail_test.go b/internal/tui/view/detail/music_detail_test.go new file mode 100644 index 0000000..999a959 --- /dev/null +++ b/internal/tui/view/detail/music_detail_test.go @@ -0,0 +1,69 @@ +package detail + +import ( + "fmt" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestMusicNavigationAndMouse(t *testing.T) { + ui.GetLayout().Update(100, 40, false) + artist := NewMusicDetailView("artist", ui.PlexctlTheme) + artist.Update(detailDataMsg{ + metadata: &components.Metadata{Type: "artist", Title: "Artist"}, + children: []components.Metadata{{RatingKey: ui.Ptr("album"), Type: "album", Title: "The album"}}, + }) + artist.Update(ui.KeyPress("enter")) + album := artist.selected + if album == nil || album.RatingKey != "album" { + t.Fatal("enter did not open the selected album") + } + var tracks []components.Metadata + for i := range 20 { + tracks = append(tracks, components.Metadata{RatingKey: ui.Ptr(fmt.Sprint(i)), Type: "track", Title: fmt.Sprintf("Song %02d", i), Index: ui.Ptr(i + 1)}) + } + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "album", Title: "The album"}, children: tracks}) + album.View() // Establish the list's actual viewport size. + for range 10 { + album.Update(tea.MouseMsg{X: album.listRect.X + 3, Y: album.listRect.Y + 3, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + } + view := ansi.Strip(album.View()) + clicked := false + for y, line := range strings.Split(view, "\n") { + if x := strings.Index(line, "Song "); x >= 0 { + title := line[x : x+len("Song 00")] + album.Update(tea.MouseMsg{X: lipgloss.Width(line[:x]) + 1, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + selected := album.items.SelectedItem().(musicItem) + if album.selected == nil || selected.metadata.Title != title || album.selected.RatingKey != *selected.metadata.RatingKey { + t.Fatalf("clicked %q but opened %+v", title, album.selected) + } + clicked = true + break + } + } + if !clicked { + t.Fatal("no tracks rendered") + } + manager := DetailManager{View: artist} + manager.Update(BackMsg{}) + if artist.selected != album || album.selected != nil { + t.Fatal("back from track skipped its album") + } + if got := artist.GetSelectedMetadata(); got == nil || got.Type != "track" { + t.Fatal("album did not expose its selected track for playback") + } + manager.Update(BackMsg{}) + if manager.View != artist || artist.selected != nil { + t.Fatal("back from album skipped its artist") + } + manager.Update(BackMsg{}) + if manager.Active() { + t.Fatal("back from artist did not return to the library") + } +} diff --git a/internal/tui/view/detail/navigation_test.go b/internal/tui/view/detail/navigation_test.go new file mode 100644 index 0000000..d0aebdd --- /dev/null +++ b/internal/tui/view/detail/navigation_test.go @@ -0,0 +1,27 @@ +package detail + +import ( + "testing" + + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestBackFromEpisodePreservesSeasonAndShow(t *testing.T) { + show := NewShowDetailView("show", ui.PlexctlTheme) + season := NewSeasonDetailView("season", ui.PlexctlTheme) + season.selectedEpisode = NewEpisodeDetailView("episode", ui.PlexctlTheme) + show.selectedSeason = season + manager := DetailManager{View: show} + manager.Update(BackMsg{}) + if manager.View != show || show.selectedSeason != season || season.selectedEpisode != nil { + t.Fatal("back from episode skipped its season") + } + manager.Update(BackMsg{}) + if manager.View != show || show.selectedSeason != nil { + t.Fatal("back from season skipped its show") + } + manager.Update(BackMsg{}) + if manager.Active() { + t.Fatal("back from show did not return to library") + } +} diff --git a/internal/tui/view/detail/season_detail.go b/internal/tui/view/detail/season_detail.go index aacbf07..aeeea0f 100644 --- a/internal/tui/view/detail/season_detail.go +++ b/internal/tui/view/detail/season_detail.go @@ -17,6 +17,7 @@ type SeasonDetailView struct { children []components.Metadata episodeList list.Model selectedEpisode *EpisodeDetailView + listRect ui.Rect } func NewSeasonDetailView(ratingKey string, theme tint.Tint) *SeasonDetailView { @@ -80,6 +81,14 @@ func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmds []tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok { + if local, inside := v.listRect.Local(mouse); inside { + if ui.ListMouse(&v.episodeList, local) { + return v.Update(ui.KeyPress("enter")) + } + return v, nil + } + } switch msg := msg.(type) { case detailDataMsg: @@ -114,7 +123,9 @@ func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := v.DetailBase.Update(msg) cmds = append(cmds, cmd) - v.episodeList.SetSize(ui.GetLayout().InnerWidth()-4, ui.GetLayout().ContentHeight()/2) + var listCmd tea.Cmd + v.episodeList, listCmd = v.episodeList.Update(msg) + cmds = append(cmds, listCmd) return v, tea.Batch(cmds...) } @@ -167,6 +178,9 @@ func (v *SeasonDetailView) View() string { ) mainLayout := v.RenderPosterAndInfo(infoSection) + v.listRect = ui.Rect{X: 2, Y: 3 + lipgloss.Height(mainLayout), Width: max(1, ui.GetLayout().InnerWidth()-4)} + v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-4) + v.episodeList.SetSize(v.listRect.Width, v.listRect.Height) listContent := lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), true, false, false, false). @@ -175,5 +189,5 @@ func (v *SeasonDetailView) View() string { Render(v.episodeList.View()) return lipgloss.NewStyle().Padding(1, 2).Render(lipgloss.JoinVertical(lipgloss.Left, mainLayout, listContent)) + - "\n\n " + lipgloss.NewStyle().Foreground(v.Theme.BrightBlack()).Render("[enter] Details | [p] Play | [S] Show | [esc] Back") + "\n\n " + lipgloss.NewStyle().Foreground(v.Theme.BrightBlack()).Render("[enter] Details | [p] Play | [v] Terminal | [S] Show | [esc] Back") } diff --git a/internal/tui/view/detail/show_detail.go b/internal/tui/view/detail/show_detail.go index ff4cc79..d105c89 100644 --- a/internal/tui/view/detail/show_detail.go +++ b/internal/tui/view/detail/show_detail.go @@ -19,6 +19,7 @@ type ShowDetailView struct { children []components.Metadata seasonList list.Model selectedSeason *SeasonDetailView + listRect ui.Rect } func NewShowDetailView(ratingKey string, theme tint.Tint) *ShowDetailView { @@ -72,16 +73,24 @@ func (v *ShowDetailView) fetchData() tea.Msg { func (v *ShowDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if v.selectedSeason != nil { - newSeason, cmd := v.selectedSeason.Update(msg) - if _, ok := msg.(BackMsg); ok { + if _, ok := msg.(BackMsg); ok && v.selectedSeason.IsAtRoot() { v.selectedSeason = nil return v, nil } + newSeason, cmd := v.selectedSeason.Update(msg) v.selectedSeason = newSeason.(*SeasonDetailView) return v, cmd } var cmds []tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok { + if local, inside := v.listRect.Local(mouse); inside { + if ui.ListMouse(&v.seasonList, local) { + return v.Update(ui.KeyPress("enter")) + } + return v, nil + } + } switch msg := msg.(type) { case detailDataMsg: @@ -111,8 +120,6 @@ func (v *ShowDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.seasonList, listCmd = v.seasonList.Update(msg) cmds = append(cmds, listCmd) - v.seasonList.SetSize(ui.GetLayout().InnerWidth()-4, ui.GetLayout().ContentHeight()/2) - return v, tea.Batch(cmds...) } @@ -198,6 +205,9 @@ func (v *ShowDetailView) View() string { ) mainLayout := v.RenderPosterAndInfo(infoSection) + v.listRect = ui.Rect{X: 2, Y: 3 + lipgloss.Height(mainLayout), Width: max(1, ui.GetLayout().InnerWidth()-4)} + v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-4) + v.seasonList.SetSize(v.listRect.Width, v.listRect.Height) listContent := lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), true, false, false, false). diff --git a/internal/tui/view/history.go b/internal/tui/view/history.go index 530946e..666186b 100644 --- a/internal/tui/view/history.go +++ b/internal/tui/view/history.go @@ -16,7 +16,7 @@ import ( ) type HistoryTab struct { - table table.Model + table ui.Table width int height int @@ -56,6 +56,9 @@ func (t *HistoryTab) Refresh() tea.Cmd { func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok && t.table.Click(mouse) { + msg = ui.KeyPress("enter") + } switch msg := msg.(type) { case tea.WindowSizeMsg: t.width = msg.Width diff --git a/internal/tui/view/home.go b/internal/tui/view/home.go index 1e14417..b6eec3a 100644 --- a/internal/tui/view/home.go +++ b/internal/tui/view/home.go @@ -69,6 +69,29 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { shouldRedraw := false switch msg := msg.(type) { + case tea.MouseMsg: + if delta := ui.WheelDelta(msg); delta != 0 && !msg.Shift && + msg.Button != tea.MouseButtonWheelLeft && msg.Button != tea.MouseButtonWheelRight { + v.viewport.SetYOffset(v.viewport.YOffset + delta*3) + return v, nil + } + y := -v.viewport.YOffset + for i, l := range v.lists { + height := lipgloss.Height(l.View()) + local, inside := (ui.Rect{Y: y, Width: v.viewport.Width, Height: height}).Local(msg) + if inside { + if ui.LeftClick(msg) || ui.WheelDelta(msg) != 0 { + v.lists[v.activeList].Active = false + v.activeList = i + l.Active = true + } + _, cmd := l.Update(local) + v.updateViewportContent() + return v, cmd + } + y += height + } + return v, nil case tea.WindowSizeMsg: layout := ui.GetLayout() v.viewport.Width = layout.InnerWidth() @@ -140,6 +163,8 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { dv = detail.NewShowDetailView(ratingKey, v.theme) case "episode": dv = detail.NewEpisodeDetailView(ratingKey, v.theme) + case "artist", "album", "track": + dv = detail.NewMusicDetailView(ratingKey, v.theme) default: dv = detail.NewMovieDetailView(ratingKey, v.theme) } @@ -191,7 +216,10 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (v *HomeView) syncViewport() { - targetLine := v.activeList * (ui.PosterTotalHeight + 1) + targetLine := 0 + for i := 0; i < v.activeList; i++ { + targetLine += lipgloss.Height(v.lists[i].View()) + } v.viewport.SetYOffset(targetLine) } @@ -240,6 +268,13 @@ func (v *HomeView) View() string { Render(v.viewport.View()) } +func (v *HomeView) ActionRows() int { + if v.details.Active() { + return 1 + } + return 0 +} + func (v *HomeView) HelpKeys() []ui.HelpKey { if v.details.Active() { if provider, ok := v.details.View.(ui.HelpProvider); ok { diff --git a/internal/tui/view/libraries.go b/internal/tui/view/libraries.go index a045a6e..ed7d42b 100644 --- a/internal/tui/view/libraries.go +++ b/internal/tui/view/libraries.go @@ -17,7 +17,7 @@ type LibrarySelectedMsg struct { } type LibrariesTab struct { - table table.Model + table ui.Table width int height int } @@ -86,6 +86,9 @@ func (t *LibrariesTab) fetchLibraries() tea.Msg { func (t *LibrariesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok && t.table.Click(mouse) { + msg = ui.KeyPress("enter") + } switch msg := msg.(type) { case tea.WindowSizeMsg: t.width = msg.Width diff --git a/internal/tui/view/media.go b/internal/tui/view/media.go index 50a83ba..c463fb4 100644 --- a/internal/tui/view/media.go +++ b/internal/tui/view/media.go @@ -30,7 +30,7 @@ const ( type MediaView struct { sectionID string title string - table table.Model + table ui.Table posterGrid *poster.PosterGrid theme tint.Tint err error @@ -178,6 +178,9 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmds []tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok && v.viewMode == ViewModeList && v.table.Click(mouse) { + msg = ui.KeyPress("enter") + } switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -199,11 +202,15 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case poster.PosterLoadedMsg: _, cmd := v.posterGrid.Update(msg) return v, cmd + case poster.ItemSelectedMsg: + if msg.Metadata.RatingKey != nil { + return v, v.ShowDetail(*msg.Metadata.RatingKey, msg.Metadata.Type) + } case error: v.err = msg case tea.KeyMsg: switch msg.String() { - case "v": + case "V": cfg := config.Get() id, server, ok := cfg.GetActiveServer() @@ -305,6 +312,8 @@ func (v *MediaView) ShowDetail(ratingKey, mediaType string) tea.Cmd { dv = detail.NewSeasonDetailView(ratingKey, v.theme) case "episode": dv = detail.NewEpisodeDetailView(ratingKey, v.theme) + case "artist", "album", "track": + dv = detail.NewMusicDetailView(ratingKey, v.theme) default: dv = detail.NewMovieDetailView(ratingKey, v.theme) } @@ -323,7 +332,7 @@ func (v *MediaView) HelpKeys() []ui.HelpKey { } return []ui.HelpKey{ {Key: "enter", Desc: "View Details"}, - {Key: "v", Desc: "Toggle View"}, + {Key: "V", Desc: "Toggle List / Poster View"}, {Key: "j/up", Desc: "Move Up"}, {Key: "k/down", Desc: "Move Down"}, } @@ -341,6 +350,13 @@ func (v *MediaView) View() string { return v.posterGrid.View() } +func (v *MediaView) ActionRows() int { + if v.details.Active() { + return 1 + } + return 0 +} + func (v *MediaView) syncTableRows() { items := presenters.MapMetadata(v.allMetadata) rows := make([]table.Row, len(items)) diff --git a/internal/tui/view/sessions.go b/internal/tui/view/sessions.go index b5fce26..e05ff3d 100644 --- a/internal/tui/view/sessions.go +++ b/internal/tui/view/sessions.go @@ -15,7 +15,7 @@ import ( ) type SessionsTab struct { - table table.Model + table ui.Table width int height int } diff --git a/internal/tui/view/tasks.go b/internal/tui/view/tasks.go index 19149c1..9159be3 100644 --- a/internal/tui/view/tasks.go +++ b/internal/tui/view/tasks.go @@ -14,7 +14,7 @@ import ( ) type TasksTab struct { - table table.Model + table ui.Table width int height int } diff --git a/internal/tui/widget/config/config.go b/internal/tui/widget/config/config.go index d0a893b..23a0f01 100644 --- a/internal/tui/widget/config/config.go +++ b/internal/tui/widget/config/config.go @@ -36,6 +36,8 @@ type ConfigOverlayModel struct { focusSide int // 0: Hidden, 1: Active isPicking bool currentIconType config.IconType + listRects [2]ui.Rect + actionRows int } type ConfigFinishedMsg struct { @@ -137,6 +139,13 @@ func (m *ConfigOverlayModel) Init() tea.Cmd { return nil } +func (m *ConfigOverlayModel) ActionRows() int { + if m.isPicking { + return 0 // The icon picker handles its own action bar. + } + return m.actionRows +} + func (m *ConfigOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -150,6 +159,11 @@ func (m *ConfigOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.picker = nil return m, nil } + if mouse, ok := msg.(tea.MouseMsg); ok { + mouse.X -= 3 + mouse.Y -= 2 + msg = mouse + } _, cmd = m.picker.Update(msg) @@ -191,9 +205,19 @@ func (m *ConfigOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case tea.MouseMsg: + for i, rect := range m.listRects { + if local, inside := rect.Local(msg); inside { + m.focusSide = i + if ui.ListMouse(&m.lists[i], local) { + return m.Update(ui.KeyPress("enter")) + } + return m, nil + } + } case tea.WindowSizeMsg: for i := range m.lists { - m.lists[i].SetSize(30, 15) + m.lists[i].SetSize(max(8, min(30, (msg.Width-12)/2)), max(5, min(15, msg.Height-10))) } case tea.KeyMsg: switch msg.String() { @@ -331,22 +355,29 @@ func (m *ConfigOverlayModel) View() string { BorderForeground(ui.Accent(theme)) var views []string + x := 3 for i := range m.lists { lView := m.lists[i].View() + left := 1 if m.focusSide == i { - views = append(views, activeListStyle.Render(lView)) + views = append(views, activeListStyle.Width(m.lists[i].Width()+2).Render(lView)) + left++ } else { - views = append(views, listStyle.Render(lView)) + views = append(views, listStyle.Width(m.lists[i].Width()+2).Render(lView)) } + m.listRects[i] = ui.Rect{X: x + left, Y: 4, Width: m.lists[i].Width(), Height: m.lists[i].Height()} + x += lipgloss.Width(views[i]) } content := lipgloss.JoinHorizontal(lipgloss.Top, views...) modeLabel := lipgloss.NewStyle().Foreground(theme.BrightBlue()).Bold(true).Render(" Mode: " + string(m.currentIconType)) + actions := lipgloss.NewStyle().Width(lipgloss.Width(content)).Render("[tab] Switch | [enter] Icon | [h] Hide\n[m] Type | [j/k] Order | [esc] Finish") + m.actionRows = lipgloss.Height(actions) return overlayStyle.Render(lipgloss.JoinVertical(lipgloss.Left, modeLabel, content, - "\n [arrows/tab] switch | [enter] icon | [m] type | [h] hide | [j/k] order | [esc] finish", + "\n"+actions, )) } diff --git a/internal/tui/widget/config/mouse_test.go b/internal/tui/widget/config/mouse_test.go new file mode 100644 index 0000000..2bf10a0 --- /dev/null +++ b/internal/tui/widget/config/mouse_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/plex" +) + +func TestLibraryClickBesideEmptyColumn(t *testing.T) { + for _, width := range []int{50, 83, 120} { + m := NewConfigOverlayModel([]plex.LibraryInfo{{ID: "test", Title: "Mouse QA"}}) + m.Update(tea.WindowSizeMsg{Width: width, Height: 30}) + view := m.View() + if lipgloss.Width(view) > width { + t.Fatalf("dialog width %d exceeds terminal width %d", lipgloss.Width(view), width) + } + clicked := false + for y, line := range strings.Split(ansi.Strip(view), "\n") { + if x := strings.Index(line, "Mouse QA"); x >= 0 { + m.Update(tea.MouseMsg{X: lipgloss.Width(line[:x]) + 1, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + clicked = true + break + } + } + if !clicked || !m.isPicking || m.focusSide != 1 { + t.Fatalf("width %d: active-column click did not open its icon picker (side=%d)", width, m.focusSide) + } + } +} diff --git a/internal/tui/widget/help/help.go b/internal/tui/widget/help/help.go index 36d33d2..0b997e5 100644 --- a/internal/tui/widget/help/help.go +++ b/internal/tui/widget/help/help.go @@ -3,6 +3,7 @@ package help import ( "strings" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" @@ -10,10 +11,12 @@ import ( ) type HelpOverlayModel struct { - keys []ui.HelpKey - theme tint.Tint - width int - height int + keys []ui.HelpKey + theme tint.Tint + width int + height int + offset int + maxOffset int } func NewHelpOverlayModel(keys []ui.HelpKey, theme tint.Tint) *HelpOverlayModel { @@ -27,8 +30,12 @@ func (m *HelpOverlayModel) Init() tea.Cmd { return nil } +func (m *HelpOverlayModel) ActionRows() int { return 1 } + func (m *HelpOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + m.offset = max(0, min(m.maxOffset, m.offset+ui.WheelDelta(msg)*3)) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -36,6 +43,10 @@ func (m *HelpOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "q", "?": return nil, nil // Signal to dismiss + case "up", "k": + m.offset = max(0, m.offset-1) + case "down", "j": + m.offset = min(m.maxOffset, m.offset+1) } } return m, nil @@ -43,6 +54,8 @@ func (m *HelpOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *HelpOverlayModel) View() string { var sb strings.Builder + boxWidth := max(20, min(m.width-2, 90)) + contentWidth := boxWidth - 4 accent := ui.Accent(m.theme) @@ -57,10 +70,7 @@ func (m *HelpOverlayModel) View() string { Width(15) descStyle := lipgloss.NewStyle(). - Foreground(m.theme.White()) - - sb.WriteString(titleStyle.Render(" COMMANDS ")) - sb.WriteString("\n\n") + Foreground(m.theme.White()).Width(max(1, contentWidth-15)) for _, k := range m.keys { row := lipgloss.JoinHorizontal(lipgloss.Top, @@ -70,15 +80,20 @@ func (m *HelpOverlayModel) View() string { sb.WriteString(row + "\n") } - sb.WriteString("\n" + lipgloss.NewStyle().Foreground(m.theme.BrightBlack()).Render(" Press esc, q, or ? to close ")) - - content := sb.String() + rows := strings.TrimSuffix(sb.String(), "\n") + vp := viewport.New(contentWidth, max(1, m.height-8)) + vp.SetContent(rows) + vp.SetYOffset(m.offset) + m.offset = vp.YOffset + m.maxOffset = max(0, lipgloss.Height(rows)-vp.Height) + content := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(" COMMANDS "), vp.View(), "", + lipgloss.NewStyle().Foreground(m.theme.BrightBlack()).Render(" [esc] Close | Wheel: scroll ")) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(accent). Background(lipgloss.Color("#1a1a1a")). Padding(1, 2). - Width(max(m.width/2, 45)). + Width(boxWidth). Render(content) } diff --git a/internal/tui/widget/iconpicker/common.go b/internal/tui/widget/iconpicker/common.go index e214999..97b456f 100644 --- a/internal/tui/widget/iconpicker/common.go +++ b/internal/tui/widget/iconpicker/common.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/ui" ) type Mode int @@ -103,6 +104,42 @@ func (p *IconPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.MouseMsg: + if key := ui.ShortcutAt(p.View(), msg); msg.Y == lipgloss.Height(p.View())-1 && key != "" { + return p.Update(ui.KeyPress(key)) + } + if delta := ui.WheelDelta(msg); delta != 0 { + key := "down" + if delta < 0 { + key = "up" + } + p.handleKeys(ui.KeyPress(key)) + return p, nil + } + if ui.LeftClick(msg) { + switch p.Mode { + case GridMode: + row := msg.Y - 2 + if row >= 0 && row < len(p.Grid) { + x := 0 + for col, icon := range p.Grid[row] { + width := lipgloss.Width(icon.Char) + 2 + if msg.X >= x && msg.X < x+width { + p.Row, p.Col = row, col + return p.Update(ui.KeyPress("enter")) + } + x += width + } + } + case SearchMode: + index := msg.Y - 4 + if index >= 0 && index < len(p.Results) { + p.ResultIdx = index + return p.Update(ui.KeyPress("enter")) + } + } + } + return p, nil case tea.KeyMsg: switch msg.String() { case "tab": @@ -158,6 +195,9 @@ func (p *IconPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *IconPicker) handleKeys(msg tea.KeyMsg) { switch p.Mode { case GridMode: + if len(p.Grid) == 0 { + return + } switch msg.String() { case "left": p.Col = max(0, p.Col-1) @@ -165,6 +205,7 @@ func (p *IconPicker) handleKeys(msg tea.KeyMsg) { p.Col = min(len(p.Grid[p.Row])-1, p.Col+1) case "up": p.Row = max(0, p.Row-1) + p.Col = min(p.Col, len(p.Grid[p.Row])-1) case "down": p.Row = min(len(p.Grid)-1, p.Row+1) if p.Row < len(p.Grid) { @@ -234,7 +275,7 @@ func (p *IconPicker) View() string { content = p.manualView() } - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginTop(1).Render("tab: switch mode | /: search | enter: select") + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginTop(1).Render("[tab] Mode | [/] Search | [enter] Select") return lipgloss.NewStyle().Width(60).Render( lipgloss.JoinVertical(lipgloss.Left, diff --git a/internal/tui/widget/navbar/navbar.go b/internal/tui/widget/navbar/navbar.go index 0a88a72..ef29085 100644 --- a/internal/tui/widget/navbar/navbar.go +++ b/internal/tui/widget/navbar/navbar.go @@ -32,6 +32,11 @@ type Navbar struct { iconType config.IconType nameFormat config.LibraryNameFormat customIcons map[string]string + offset int + followActive bool + itemRows []ui.Rect + actionRows []string + actionTop int } func NewNavbar(sections []NavSection, theme tint.Tint) *Navbar { @@ -59,6 +64,23 @@ func NewNavbar(sections []NavSection, theme tint.Tint) *Navbar { func (n *Navbar) SetActive(index int) { n.activeFlatIdx = index + n.followActive = true +} + +func (n *Navbar) Scroll(lines int) { n.offset = max(0, n.offset+lines) } + +func (n *Navbar) ItemAt(mouse tea.MouseMsg) int { + for i, rect := range n.itemRows { + if _, ok := rect.Local(mouse); ok && mouse.Y < n.actionTop { + return i + } + } + return -1 +} + +func (n *Navbar) ActionAt(mouse tea.MouseMsg) string { + mouse.Y -= n.actionTop + return ui.ShortcutAt(strings.Join(n.actionRows, "\n"), mouse) } func (n *Navbar) SetCustomIcons(icons map[string]string) { @@ -79,6 +101,7 @@ func (n *Navbar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (n *Navbar) View() string { var renderedLines []string + n.itemRows = nil accent := ui.Accent(n.theme) @@ -123,7 +146,9 @@ func (n *Navbar) View() string { } for _, item := range section.Items { - content := n.formatItem(item) + content := ui.Ellipsis(n.formatItem(item), max(1, n.Width-5)) + y := lipgloss.Height(lipgloss.JoinVertical(lipgloss.Left, renderedLines...)) + n.itemRows = append(n.itemRows, ui.Rect{Y: y, Width: n.Width, Height: 1}) if flatIdx == n.activeFlatIdx { renderedLines = append(renderedLines, activeStyle.Render(content)) } else { @@ -133,7 +158,31 @@ func (n *Navbar) View() string { } } - sidebarContent := lipgloss.JoinVertical(lipgloss.Left, renderedLines...) + lines := strings.Split(lipgloss.JoinVertical(lipgloss.Left, renderedLines...), "\n") + n.actionRows = []string{"", " [/] Search", " [ctrl+s] Settings", " [ctrl+l] Libraries", " [u] Switch user"} + if n.Height < 12 { + n.actionRows = nil + } + n.actionTop = max(1, n.Height-len(n.actionRows)) + if n.followActive && n.activeFlatIdx >= 0 && n.activeFlatIdx < len(n.itemRows) { + y := n.itemRows[n.activeFlatIdx].Y + if y < n.offset { + n.offset = y + } else if y >= n.offset+n.actionTop { + n.offset = y - n.actionTop + 1 + } + n.followActive = false + } + n.offset = min(n.offset, max(0, len(lines)-n.actionTop)) + for i := range n.itemRows { + n.itemRows[i].Y -= n.offset + } + visible := append([]string(nil), lines[n.offset:min(len(lines), n.offset+n.actionTop)]...) + for len(visible) < n.actionTop { + visible = append(visible, "") + } + visible = append(visible, n.actionRows...) + sidebarContent := strings.Join(visible, "\n") // Container style with right border and fixed height return lipgloss.NewStyle(). diff --git a/internal/tui/widget/playback/mouse_test.go b/internal/tui/widget/playback/mouse_test.go new file mode 100644 index 0000000..a4d13ae --- /dev/null +++ b/internal/tui/widget/playback/mouse_test.go @@ -0,0 +1,43 @@ +package playback + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestClickSelectsRendererAtDifferentWidths(t *testing.T) { + for _, width := range []int{50, 100} { + m := New("42", "A movie with a long title", ui.PlexctlTheme) + m.Update(tea.WindowSizeMsg{Width: width, Height: 30}) + clicked := false + for y, line := range strings.Split(ansi.Strip(m.View()), "\n") { + x := strings.Index(line, "Kitty graphics") + if x < 0 { + continue + } + mouse := tea.MouseMsg{X: x + 2, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} + if next, cmd := m.Update(mouse); next == nil || cmd != nil { + t.Fatal("release activated renderer") + } + mouse.Action = tea.MouseActionPress + next, cmd := m.Update(mouse) + if next != nil || cmd == nil { + t.Fatal("renderer click did not close menu and request playback") + } + request := cmd().(ui.RequestPlayMsg) + if request.RatingKey != "42" || request.Output != config.VideoKitty { + t.Fatalf("wrong playback request: %#v", request) + } + clicked = true + break + } + if !clicked { + t.Fatal("Kitty renderer was not visible") + } + } +} diff --git a/internal/tui/widget/playback/playback.go b/internal/tui/widget/playback/playback.go new file mode 100644 index 0000000..0871798 --- /dev/null +++ b/internal/tui/widget/playback/playback.go @@ -0,0 +1,112 @@ +package playback + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" + "github.com/ygelfand/plexctl/internal/ui" +) + +var choices = []struct { + label string + output config.VideoOutput +}{ + {"Automatic", config.VideoAuto}, + {"Kitty graphics", config.VideoKitty}, + {"Sixel graphics", config.VideoSixel}, + {"Text (compatible terminals)", config.VideoTCT}, +} + +type Model struct { + ratingKey string + title string + theme tint.Tint + choice int + width int +} + +func New(ratingKey, title string, theme tint.Tint) *Model { + m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58} + for i, choice := range choices { + if choice.output == config.Get().TerminalVideo { + m.choice = i + } + } + return m +} + +func (m *Model) Init() tea.Cmd { return nil } + +func (m *Model) ActionRows() int { return 1 } + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.MouseMsg: + if delta := ui.WheelDelta(msg); delta != 0 { + m.choice = max(0, min(len(choices)-1, m.choice+delta)) + } else if ui.LeftClick(msg) { + for i, choice := range choices { + if ui.LabelAt(m.View(), msg, choice.label) { + m.choice = i + return m.Update(ui.KeyPress("enter")) + } + } + } + case tea.WindowSizeMsg: + m.width = max(20, min(58, msg.Width-8)) + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + m.choice = (m.choice + len(choices) - 1) % len(choices) + case "down", "j": + m.choice = (m.choice + 1) % len(choices) + case "esc", "q": + return nil, nil + case "enter": + output := choices[m.choice].output + return nil, func() tea.Msg { + return ui.RequestPlayMsg{RatingKey: m.ratingKey, Output: output} + } + } + } + return m, nil +} + +func (m *Model) View() string { + accent := lipgloss.NewStyle().Foreground(ui.Accent(m.theme)).Bold(true) + lines := []string{accent.Render("Play in terminal"), ui.Ellipsis(m.title, m.width-4), ""} + for i, choice := range choices { + line := " " + choice.label + if m.choice == i { + line = accent.Render("> " + choice.label) + } + lines = append(lines, line) + } + caps := termgraphics.Current() + detected := "Choose a renderer supported by your terminal." + switch { + case caps.Kitty: + detected = "Detected Kitty graphics." + if caps.NativePosters() { + detected += " Native posters enabled." + } + case caps.Sixel: + detected = "Detected Sixel graphics." + case caps.Responded: + name := caps.Name + if name == "" { + name = "This terminal" + } + detected = fmt.Sprintf("%s: text output available. Open Kitty for graphics.", name) + } + lines = append(lines, "", lipgloss.NewStyle().Width(m.width-4).Render(detected), "", + "Space / click: pause Arrows / wheel: seek", "Right click / q: return", "", "[enter] Play | [esc] Cancel") + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.Accent(m.theme)).Padding(1, 2).Width(m.width). + Render(strings.Join(lines, "\n")) +} diff --git a/internal/tui/widget/poster/grid.go b/internal/tui/widget/poster/grid.go index 2ee4b04..e130d03 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" - "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) @@ -27,7 +27,7 @@ func NewPosterGrid(metadata []components.Metadata, theme tint.Tint) *PosterGrid rk = *m.RatingKey } if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { items[i].Poster = cached items[i].Loading = false } @@ -57,7 +57,7 @@ func (m *PosterGrid) SetItems(metadata []components.Metadata) tea.Cmd { rk = *meta.RatingKey } if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -65,6 +65,7 @@ func (m *PosterGrid) SetItems(metadata []components.Metadata) tea.Cmd { } } m.Items = newItems + m.Cursor = min(m.Cursor, max(0, len(m.Items)-1)) return m.fetchVisible() } @@ -104,6 +105,8 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { layout := ui.GetLayout() m.viewport.Width = layout.MainAreaContentWidth() m.viewport.Height = layout.ContentHeight() + m.View() // Reflow the viewport before restoring the selected row. + cmds = append(cmds, m.syncScroll()) cmds = append(cmds, m.fetchVisible()) case PosterLoadedMsg: @@ -111,6 +114,24 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Items[msg.Index].Poster = msg.View m.Items[msg.Index].Loading = false } + case tea.MouseMsg: + if _, inside := (ui.Rect{Width: m.viewport.Width, Height: m.viewport.Height}).Local(msg); !inside { + return m, nil + } + if delta := ui.WheelDelta(msg); delta != 0 { + m.viewport.SetYOffset(m.viewport.YOffset + delta*3) + return m, m.fetchVisible() + } + if ui.LeftClick(msg) { + cols := ui.GetLayout().PosterColumns() + column := msg.X / ui.PosterStepWidth + row := (msg.Y + m.viewport.YOffset) / ui.PosterTotalHeight + index := row*cols + column + if column < cols && msg.X%ui.PosterStepWidth < ui.PosterWidth+ui.PosterBorder && index < len(m.Items) { + m.Cursor = index + return m.Update(ui.KeyPress("enter")) + } + } case tea.KeyMsg: cols := ui.GetLayout().PosterColumns() diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index 5046a5c..119167b 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -14,8 +14,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" - gopixels "github.com/saran13raj/go-pixels" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) @@ -44,7 +44,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { } if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { return PosterLoadedMsg{ListID: listID, Index: index, View: cached} } } @@ -72,15 +72,11 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { return nil } - imgStr, err := gopixels.FromImageStream(img, ui.PosterWidth, 0, "halfcell", true) + imgStr, err := artwork.Render(rk, img, ui.PosterWidth, ui.PosterHeight) if err != nil { return nil } - if rk != "" { - plex.SetCachedPoster(rk, ui.PosterWidth, imgStr) - } - return PosterLoadedMsg{ListID: listID, Index: index, View: imgStr} } } @@ -112,6 +108,7 @@ func RenderPosterItem(item *PosterItem, isSelected bool, active bool, theme tint label := GetDisplayTitle(&item.Metadata) labelStyle := lipgloss.NewStyle(). Width(ui.PosterWidth). + Height(2). Align(lipgloss.Center). Foreground(theme.White()). MaxHeight(2) diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 4164fc8..6108098 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" - "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) @@ -29,7 +29,7 @@ func NewPosterList(id int, title string, metadata []components.Metadata, theme t rk = *m.RatingKey } if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { items[i].Poster = cached items[i].Loading = false } @@ -60,7 +60,7 @@ func (m *PosterList) SetItems(metadata []components.Metadata) tea.Cmd { rk = *meta.RatingKey } if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -110,6 +110,23 @@ func (m *PosterList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case tea.MouseMsg: + if delta := ui.WheelDelta(msg); delta != 0 { + key := "right" + if delta < 0 { + key = "left" + } + return m.Update(ui.KeyPress(key)) + } + titleHeight := lipgloss.Height(ui.TitleStyle(m.Theme).Render(m.Title)) + if ui.LeftClick(msg) && msg.X >= 0 && msg.Y >= titleHeight && msg.Y < titleHeight+ui.PosterTotalHeight { + column := msg.X / ui.PosterStepWidth + index := m.Offset + column + if column < ui.GetLayout().PosterColumns() && msg.X%ui.PosterStepWidth < ui.PosterWidth+ui.PosterBorder && index < len(m.Items) { + m.Cursor = index + return m.Update(ui.KeyPress("enter")) + } + } case tea.KeyMsg: switch msg.String() { case "enter": diff --git a/internal/tui/widget/poster/mouse_test.go b/internal/tui/widget/poster/mouse_test.go new file mode 100644 index 0000000..ea5ebfc --- /dev/null +++ b/internal/tui/widget/poster/mouse_test.go @@ -0,0 +1,51 @@ +package poster + +import ( + "fmt" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestPosterGridMouseAfterScrollingAndResize(t *testing.T) { + ui.GetLayout().Update(90, 25, false) + m := NewPosterGrid(nil, ui.PlexctlTheme) + for i := range 30 { + m.Items = append(m.Items, &PosterItem{Metadata: components.Metadata{Title: fmt.Sprintf("Movie %d", i)}}) + } + m.Update(tea.WindowSizeMsg{Width: 90, Height: 25}) + m.View() + for range 6 { + m.Update(tea.MouseMsg{X: 5, Y: 5, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + } + _, cmd := m.Update(tea.MouseMsg{X: ui.PosterStepWidth + 2, Y: 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if cmd == nil { + t.Fatal("visible poster did not open") + } + selected := cmd().(ItemSelectedMsg) + if selected.Metadata.Title != "Movie 4" { + t.Fatalf("scrolled click selected %s", selected.Metadata.Title) + } + _, cmd = m.Update(tea.MouseMsg{X: ui.PosterStepWidth - 1, Y: 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if cmd != nil { + t.Fatal("poster margin activated a movie") + } + ui.GetLayout().Update(50, 25, false) + m.Update(tea.WindowSizeMsg{Width: 50, Height: 25}) + if m.Cursor != 4 { + t.Fatalf("resize changed selection to %d", m.Cursor) + } +} + +func TestPosterHeightDoesNotChangeWithSelection(t *testing.T) { + item := &PosterItem{Metadata: components.Metadata{Title: "A long movie title that wraps"}} + for _, selected := range []bool{false, true} { + view := RenderPosterItem(item, selected, true, ui.PlexctlTheme) + if height := lipgloss.Height(view); height != ui.PosterTotalHeight { + t.Fatalf("selected %v: poster height %d, want %d", selected, height, ui.PosterTotalHeight) + } + } +} diff --git a/internal/tui/widget/resume/resume.go b/internal/tui/widget/resume/resume.go index 74952b5..4a0659c 100644 --- a/internal/tui/widget/resume/resume.go +++ b/internal/tui/widget/resume/resume.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/tui/player" "github.com/ygelfand/plexctl/internal/ui" ) @@ -15,22 +16,23 @@ import ( type ResumeDecisionMsg struct { Resume bool Metadata *components.Metadata - TctMode bool + Output config.VideoOutput } type ResumeOverlayModel struct { Metadata *components.Metadata - TctMode bool + Output config.VideoOutput theme tint.Tint choice int // 0: Resume, 1: Start from beginning width int height int + options [2]ui.Rect } -func NewResumeOverlayModel(metadata *components.Metadata, tctMode bool, theme tint.Tint) *ResumeOverlayModel { +func NewResumeOverlayModel(metadata *components.Metadata, output config.VideoOutput, theme tint.Tint) *ResumeOverlayModel { return &ResumeOverlayModel{ Metadata: metadata, - TctMode: tctMode, + Output: output, theme: theme, choice: 0, } @@ -40,8 +42,22 @@ func (m *ResumeOverlayModel) Init() tea.Cmd { return nil } +func (m *ResumeOverlayModel) ActionRows() int { return 1 } + func (m *ResumeOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + if delta := ui.WheelDelta(msg); delta != 0 { + m.choice = max(0, min(1, m.choice+delta)) + } else if ui.LeftClick(msg) { + m.View() + for i, rect := range m.options { + if _, ok := rect.Local(msg); ok { + m.choice = i + return m.Update(ui.KeyPress("enter")) + } + } + } case tea.KeyMsg: switch msg.String() { case "up", "k": @@ -51,10 +67,10 @@ func (m *ResumeOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": resume := m.choice == 0 offset := int64(0) - if resume && m.Metadata.ViewOffset != nil { + if resume && m.Metadata != nil && m.Metadata.ViewOffset != nil { offset = int64(*m.Metadata.ViewOffset) } - return nil, player.PlayMedia(m.Metadata, false, m.TctMode, offset) + return nil, player.PlayMedia(m.Metadata, false, m.Output, offset) case "esc": return nil, nil } @@ -66,7 +82,7 @@ func (m *ResumeOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *ResumeOverlayModel) View() string { - boxWidth := 40 + boxWidth := max(20, min(50, m.width-6)) title := "Resume Playback?" if m.Metadata != nil { title = fmt.Sprintf("Resume %s?", m.Metadata.Title) @@ -77,6 +93,7 @@ func (m *ResumeOverlayModel) View() string { titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(accent). + Width(boxWidth - 4). MarginBottom(1) optionStyle := lipgloss.NewStyle().PaddingLeft(2) @@ -107,12 +124,17 @@ func (m *ResumeOverlayModel) View() string { options = append(options, "󰄬 "+selectedStyle.Render("Start from beginning")) } + heading := titleStyle.Render(title) + optionTop := 2 + lipgloss.Height(heading) + 1 + for i := range m.options { + m.options[i] = ui.Rect{X: 3, Y: optionTop + i, Width: boxWidth - 4, Height: 1} + } content := lipgloss.JoinVertical(lipgloss.Left, - titleStyle.Render(title), + heading, "", strings.Join(options, "\n"), "", - lipgloss.NewStyle().Foreground(m.theme.BrightBlack()).Render("[↑/↓] Select [enter] Confirm [esc] Cancel"), + lipgloss.NewStyle().Foreground(m.theme.BrightBlack()).Render("[enter] Confirm | [esc] Cancel"), ) return lipgloss.NewStyle(). diff --git a/internal/tui/widget/search/overlay.go b/internal/tui/widget/search/overlay.go index 743826a..eb8d35a 100644 --- a/internal/tui/widget/search/overlay.go +++ b/internal/tui/widget/search/overlay.go @@ -69,10 +69,18 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.MouseMsg: + msg.X -= 3 + msg.Y -= 4 + if ui.ListMouse(&m.list, msg) { + return m.Update(ui.KeyPress("enter")) + } + return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.list.SetSize(max(m.width/2, 60), max(m.height/2, 20)) + m.list.SetSize(max(1, min(m.width-8, max(m.width/2, 60))), max(5, min(m.height-8, 24))) + m.textInput.Width = max(1, m.list.Width()-4) case tea.KeyMsg: switch msg.String() { case "esc": diff --git a/internal/tui/widget/settings/settings.go b/internal/tui/widget/settings/settings.go index b709ee6..b7025cd 100644 --- a/internal/tui/widget/settings/settings.go +++ b/internal/tui/widget/settings/settings.go @@ -77,6 +77,7 @@ func (m *SettingsOverlayModel) updateItems() { settingItem{id: "cache", title: "Enable Cache", description: "Cache Plex data locally", current: fmt.Sprintf("%v", !cfg.NoCache)}, settingItem{id: "auto_home_login", title: "Auto Home Login", description: "Login automatically if token exists", current: fmt.Sprintf("%v", cfg.AutoHomeLogin)}, settingItem{id: "close_video_on_quit", title: "Close Video On Quit", description: "Close mpv when exiting app", current: fmt.Sprintf("%v", cfg.CloseVideoOnQuit)}, + settingItem{id: "terminal_video", title: "Terminal Video", description: "Video renderer for Ctrl+P", current: string(cfg.TerminalVideo)}, } m.list.SetItems(items) } @@ -85,15 +86,38 @@ func (m *SettingsOverlayModel) Init() tea.Cmd { return nil } +func (m *SettingsOverlayModel) ActionRows() int { return 1 } + func (m *SettingsOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if mouse, ok := msg.(tea.MouseMsg); ok { + mouse.X -= 3 + mouse.Y -= 2 + active := &m.list + if m.isSelecting { + active = &m.selectionList + } + previous := active.Index() + clicked := ui.ListMouse(active, mouse) + var preview tea.Cmd + if m.isSelecting && m.activeSetting == "theme" && previous != active.Index() { + selected := m.tints[active.Index()] + m.theme = selected + preview = func() tea.Msg { return ui.ThemeChangedMsg{Theme: selected} } + } + if clicked { + model, cmd := m.Update(ui.KeyPress("enter")) + return model, tea.Batch(cmd, preview) + } + return m, preview + } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - listW := min(m.width-2, 88) - listH := min(m.height-10, 30) + listW := max(1, min(m.width-6, 88)) + listH := max(5, min(m.height-8, 30)) m.list.SetSize(listW, listH) m.selectionList.SetSize(listW, listH) } @@ -114,7 +138,7 @@ func (m *SettingsOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, func() tea.Msg { return ui.ThemeChangedMsg{Theme: selected} }) } - if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "enter" { + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "enter" && m.selectionList.SelectedItem() != nil { selected := m.selectionList.SelectedItem().(selectionItem) m.applySetting(m.activeSetting, selected.id) m.isSelecting = false @@ -199,6 +223,14 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { selectionItem{id: string(config.ViewModeList), value: "List"}, selectionItem{id: string(config.ViewModePoster), value: "Poster"}, } + case "terminal_video": + m.selectionList.Title = "Choose Terminal Video Renderer" + items = []list.Item{ + selectionItem{id: string(config.VideoAuto), value: "Auto (detect terminal)"}, + selectionItem{id: string(config.VideoKitty), value: "Kitty graphics"}, + selectionItem{id: string(config.VideoSixel), value: "Sixel graphics"}, + selectionItem{id: string(config.VideoTCT), value: "TCT (text fallback)"}, + } } m.selectionList.SetItems(items) @@ -214,6 +246,8 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { current = string(cfg.LibraryNameFormat) case "default_view_mode": current = string(cfg.DefaultViewMode) + case "terminal_video": + current = string(cfg.TerminalVideo) } for i, it := range items { @@ -235,6 +269,8 @@ func (m *SettingsOverlayModel) applySetting(setting, value string) { cfg.LibraryNameFormat = config.LibraryNameFormat(value) case "default_view_mode": cfg.DefaultViewMode = config.ViewMode(value) + case "terminal_video": + cfg.TerminalVideo = config.VideoOutput(value) } _ = cfg.Save() } diff --git a/internal/tui/widget/userpicker/overlay.go b/internal/tui/widget/userpicker/overlay.go index 5b33fd2..4e61ea0 100644 --- a/internal/tui/widget/userpicker/overlay.go +++ b/internal/tui/widget/userpicker/overlay.go @@ -33,6 +33,19 @@ func (m *UserPickerOverlayModel) Init() tea.Cmd { func (m *UserPickerOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + if m.enteringPin { + return m, nil + } + if delta := ui.WheelDelta(msg); delta != 0 { + m.cursor = max(0, min(len(m.users)-1, m.cursor+delta)) + } else if ui.LeftClick(msg) { + row := msg.Y - 4 // Border, padding, and the two-line title. + if row >= 0 && row < len(m.users) { + m.cursor = row + return m.Update(ui.KeyPress("enter")) + } + } case ui.InvalidPinMsg: m.showInvalid = true m.pinInput = "" @@ -87,6 +100,9 @@ func (m *UserPickerOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor++ } case "enter": + if len(m.users) == 0 { + return m, nil + } user := m.users[m.cursor] if user.Protected { m.enteringPin = true diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go new file mode 100644 index 0000000..0345803 --- /dev/null +++ b/internal/ui/mouse.go @@ -0,0 +1,150 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// Rect uses terminal cells, relative to the model receiving the mouse event. +type Rect struct { + X, Y, Width, Height int +} + +func (r Rect) Local(m tea.MouseMsg) (tea.MouseMsg, bool) { + if m.X < r.X || m.Y < r.Y || m.X >= r.X+r.Width || m.Y >= r.Y+r.Height { + return m, false + } + m.X -= r.X + m.Y -= r.Y + return m, true +} + +func LeftClick(m tea.MouseMsg) bool { + return m.Action == tea.MouseActionPress && m.Button == tea.MouseButtonLeft +} + +func RightClick(m tea.MouseMsg) bool { + return m.Action == tea.MouseActionPress && m.Button == tea.MouseButtonRight +} + +// WheelDelta ignores button releases and motion, which must never activate a control. +func WheelDelta(m tea.MouseMsg) int { + if m.Action != tea.MouseActionPress { + return 0 + } + switch m.Button { + case tea.MouseButtonWheelUp, tea.MouseButtonWheelLeft: + return -1 + case tea.MouseButtonWheelDown, tea.MouseButtonWheelRight: + return 1 + } + return 0 +} + +func KeyPress(key string) tea.KeyMsg { + types := map[string]tea.KeyType{ + "enter": tea.KeyEnter, "esc": tea.KeyEsc, "up": tea.KeyUp, "down": tea.KeyDown, + "left": tea.KeyLeft, "right": tea.KeyRight, "tab": tea.KeyTab, + "ctrl+s": tea.KeyCtrlS, "ctrl+l": tea.KeyCtrlL, + "↑": tea.KeyUp, "↓": tea.KeyDown, "arrows": tea.KeyTab, + } + if t, ok := types[key]; ok { + return tea.KeyMsg{Type: t} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} +} + +// LabelAt tests the actual rendered cells, including wide Unicode characters. +// It is useful for small menus whose text may wrap after a terminal resize. +func LabelAt(view string, m tea.MouseMsg, label string) bool { + lines := strings.Split(view, "\n") + if m.Y < 0 || m.Y >= len(lines) || label == "" { + return false + } + line := ansi.Strip(lines[m.Y]) + i := strings.Index(line, label) + if i < 0 { + return false + } + x := lipgloss.Width(line[:i]) + return m.X >= x && m.X < x+lipgloss.Width(label) +} + +// ShortcutAt makes visible [key] action bars clickable. Callers restrict this +// to their action bar, so media titles and descriptions cannot become actions. +func ShortcutAt(bar string, m tea.MouseMsg) string { + if !LeftClick(m) { + return "" + } + lines := strings.Split(bar, "\n") + if m.Y < 0 || m.Y >= len(lines) { + return "" + } + line := ansi.Strip(lines[m.Y]) + x := 0 + for part := range strings.SplitSeq(line, "|") { + width := lipgloss.Width(part) + if m.X >= x && m.X < x+width { + start := strings.Index(part, "[") + end := strings.Index(part, "]") + if start >= 0 && end > start { + key, _, _ := strings.Cut(part[start+1:end], "/") + // The slash key is itself a valid action. + if part[start+1:end] == "/" { + key = "/" + } + return key + } + return "" + } + x += width + 1 + } + return "" +} + +// ListMouse handles lists using Bubbles' default two-line delegate. Coordinates +// are relative to the list, excluding its parent's border and padding. +// It returns true only when a visible item was clicked, ready for activation. +func ListMouse(m *list.Model, mouse tea.MouseMsg) bool { + if _, inside := (Rect{Width: m.Width(), Height: m.Height()}).Local(mouse); !inside { + return false + } + if delta := WheelDelta(mouse); delta != 0 { + key := "down" + if delta < 0 { + key = "up" + } + *m, _ = m.Update(KeyPress(key)) + return false + } + if !LeftClick(mouse) || len(m.VisibleItems()) == 0 { + return false + } + headerHeight := 0 + if m.ShowTitle() || (m.ShowFilter() && m.FilteringEnabled()) { + title := m.Styles.Title.Render(m.Title) + " " + if m.FilterState() == list.Filtering { + title = m.FilterInput.View() + } + headerHeight = lipgloss.Height(m.Styles.TitleBar.Render(title)) + } + // All TUI lists hide the status bar; keep unsupported layouts inert. + if m.ShowStatusBar() { + return false + } + y := mouse.Y - headerHeight + if y < 0 || y%3 >= 2 { + return false + } + start, end := m.Paginator.GetSliceBounds(len(m.VisibleItems())) + i := start + y/3 + if i >= end { + return false + } + m.Select(i) + return true +} diff --git a/internal/ui/mouse_test.go b/internal/ui/mouse_test.go new file mode 100644 index 0000000..0fb7d0c --- /dev/null +++ b/internal/ui/mouse_test.go @@ -0,0 +1,98 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +func click(x, y int) tea.MouseMsg { + return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} +} + +func TestTableMouseAfterScrollResizeAndReplacement(t *testing.T) { + m := NewTable([]table.Column{{Title: "TITLE", Width: 20}}, PlexctlTheme) + rows := make([]table.Row, 30) + for i := range rows { + rows[i] = table.Row{fmt.Sprintf("Item %02d", i)} + } + m.SetRows(rows) + m.SetWidth(25) + m.SetHeight(8) + for range 16 { + m, _ = m.Update(KeyPress("down")) + } + for _, height := range []int{8, 5, 12} { + m.SetHeight(height) + lines := strings.Split(ansi.Strip(m.View()), "\n") + want := strings.TrimSpace(lines[3]) + if !m.Click(click(4, 3)) || m.SelectedRow()[0] != want { + t.Fatalf("height %d: clicked visible %q, selected %v", height, want, m.SelectedRow()) + } + if m.Click(click(4, 1)) || m.Click(click(30, 3)) || m.Click(click(4, 90)) { + t.Fatal("header or off-table click activated a row") + } + } + m.SetRows(nil) + if m.Click(click(4, 3)) { + t.Fatal("empty table activated a row") + } + _ = m.View() // Replacing a scrolled table with no rows must not panic. +} + +type mouseListItem string + +func (i mouseListItem) Title() string { return string(i) } +func (i mouseListItem) Description() string { return "Description" } +func (i mouseListItem) FilterValue() string { return string(i) } + +func TestListMouseUsesVisiblePage(t *testing.T) { + items := make([]list.Item, 20) + for i := range items { + items[i] = mouseListItem(fmt.Sprintf("Option %02d", i)) + } + m := list.New(items, list.NewDefaultDelegate(), 45, 16) + m.Title = "Choose an option" + m.SetShowStatusBar(false) + m.SetFilteringEnabled(false) + for _, index := range []int{0, 9, 18} { + m.Select(index) + lines := strings.Split(ansi.Strip(m.View()), "\n") + for y, line := range lines { + var want int + if start := strings.Index(line, "Option "); start >= 0 { + if _, err := fmt.Sscanf(line[start:], "Option %d", &want); err != nil { + t.Fatal(err) + } + if !ListMouse(&m, click(10, y)) || m.Index() != want { + t.Fatalf("page click row %d: got %d, want %d\n%s", y, m.Index(), want, m.View()) + } + } + } + } + if ListMouse(&m, click(10, 0)) { + t.Fatal("title click activated an item") + } +} + +func TestMouseIgnoresReleaseMotionAndMargins(t *testing.T) { + for _, action := range []tea.MouseAction{tea.MouseActionRelease, tea.MouseActionMotion} { + m := click(2, 3) + m.Action = action + if LeftClick(m) || ShortcutAt("[v] Video", m) != "" { + t.Fatal("release or drag activated a control") + } + } + if got := ShortcutAt("[p] Play | [v] Video | [/] Search", click(26, 0)); got != "/" { + t.Fatalf("slash action = %q", got) + } + view := "📺 [v] Video" + if !LabelAt(view, click(7, 0), "Video") || LabelAt(view, click(2, 0), "Video") { + t.Fatal("wide character label hit test is incorrect") + } +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 4b2de24..e068548 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" ) @@ -20,10 +21,11 @@ func Overlay(base, overlay string, width, height int) string { overlayHeight := lipgloss.Height(overlay) // 2. Calculate start vertical position - startY := (height - overlayHeight) / 2 + startY := max(0, (height-overlayHeight)/2) baseLines := strings.Split(base, "\n") + overlay = lipgloss.NewStyle().Width(lipgloss.Width(overlay)).Render(overlay) overlayLines := strings.Split(overlay, "\n") // Ensure base has enough lines to fill the screen @@ -46,7 +48,7 @@ func Overlay(base, overlay string, width, height int) string { result[baseY] = lipgloss.NewStyle(). Width(width). Align(lipgloss.Center). - Render(oLine) + Render(ansi.Truncate(oLine, width, "")) } return strings.Join(result, "\n") diff --git a/internal/ui/table.go b/internal/ui/table.go index 2dd5c2a..1a26351 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -2,12 +2,13 @@ package ui import ( "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" ) // NewTable creates a new bubbles/table with standard initial settings -func NewTable(columns []table.Column, theme tint.Tint) table.Model { +func NewTable(columns []table.Column, theme tint.Tint) Table { t := table.New( table.WithColumns(columns), table.WithFocused(true), @@ -25,11 +26,82 @@ func NewTable(columns []table.Column, theme tint.Tint) table.Model { Bold(false) t.SetStyles(s) - return t + return Table{Model: t} +} + +// Table keeps an explicit visible row range for accurate mouse selection after +// keyboard scrolling, pagination, appended rows, and terminal resizes. +type Table struct { + table.Model + first int +} + +func (t *Table) syncScroll() { + if t.Cursor() >= len(t.Rows()) { + t.Model.SetCursor(max(0, len(t.Rows())-1)) + } + height := max(1, t.Height()) + t.first = min(t.first, max(0, len(t.Rows())-height)) + if t.Cursor() < t.first { + t.first = max(0, t.Cursor()) + } else if t.Cursor() >= t.first+height { + t.first = t.Cursor() - height + 1 + } +} + +func (t *Table) SetHeight(height int) { t.Model.SetHeight(max(3, height)) } + +func (t *Table) SetWidth(width int) { t.Model.SetWidth(max(1, width)) } + +func (t *Table) View() string { + t.syncScroll() + visible := t.Model + end := min(len(t.Rows()), t.first+max(1, t.Height())) + visible.SetRows(t.Rows()[t.first:end]) + visible.SetCursor(0) + visible.GotoTop() + visible.SetCursor(t.Cursor() - t.first) + return visible.View() +} + +// Click selects a visible row and reports whether it can be activated. +func (t *Table) Click(mouse tea.MouseMsg) bool { + if !LeftClick(mouse) || mouse.X < 0 || mouse.X >= t.Width() { + return false + } + t.syncScroll() + // Standard tables have a one-line heading and its bottom border. + row := mouse.Y - 2 + if row < 0 || row >= t.Height() || t.first+row >= len(t.Rows()) { + return false + } + t.SetCursor(t.first + row) + return true +} + +func (t Table) Update(msg tea.Msg) (Table, tea.Cmd) { + if mouse, ok := msg.(tea.MouseMsg); ok { + if _, inside := (Rect{Width: t.Width(), Height: t.Height() + 2}).Local(mouse); inside { + switch delta := WheelDelta(mouse); { + case delta < 0: + t.MoveUp(3) + case delta > 0: + t.MoveDown(3) + default: + t.Click(mouse) + } + } + t.syncScroll() + return t, nil + } + var cmd tea.Cmd + t.Model, cmd = t.Model.Update(msg) + t.syncScroll() + return t, cmd } // UpdateTableTheme refreshes a table's styles based on the current layout theme -func UpdateTableTheme(t *table.Model, width, height int) { +func UpdateTableTheme(t *Table, width, height int) { layout := GetLayout() theme := layout.Theme() @@ -46,5 +118,5 @@ func UpdateTableTheme(t *table.Model, width, height int) { // GetTableHeight returns the appropriate table height based on available screen space func GetTableHeight(totalHeight int) int { - return max(totalHeight-5, 1) + return max(3, min(totalHeight-3, GetLayout().ContentHeight())) } diff --git a/internal/ui/types.go b/internal/ui/types.go index c50d117..416613f 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -5,6 +5,7 @@ import ( "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" ) type HelpKey struct { @@ -69,12 +70,12 @@ type PlayableProvider interface { type RequestPlayMsg struct { RatingKey string - TctMode bool + Output config.VideoOutput } type ResumeChoiceMsg struct { Metadata *components.Metadata - TctMode bool + Output config.VideoOutput } func Ptr[T any](v T) *T { From 75d213f75a0bc46824bcdeee7d1ccf81b0b21186 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:31:58 -0400 Subject: [PATCH 02/21] feat: add terminal music player and alphabetical library navigation --- README.md | 17 +- cmd/search.go | 34 +- cmd/tui.go | 8 + docs/content/tui.mdx | 68 +- go.mod | 2 +- internal/config/auth.go | 14 + internal/config/auth_test.go | 20 + internal/config/config.go | 6 + internal/config/music.go | 12 + internal/config/video.go | 2 + internal/plex/artwork_auth_test.go | 32 + internal/plex/client.go | 20 +- internal/plex/media.go | 16 +- internal/plex/playable_test.go | 6 + internal/search/index.go | 140 +++- internal/search/live.go | 57 ++ internal/search/live_test.go | 83 +++ internal/tui/music_player.go | 171 +++++ internal/tui/navigator.go | 3 + internal/tui/player/actions.go | 9 +- internal/tui/player/audio_integration_test.go | 170 +++++ internal/tui/player/ipc.go | 63 ++ internal/tui/player/manager.go | 672 ++++++++---------- internal/tui/player/player_state.go | 18 +- internal/tui/player/spectrum.go | 64 ++ internal/tui/player/spectrum_test.go | 27 + internal/tui/player/window_load.go | 17 +- internal/tui/tui.go | 115 ++- internal/tui/view/detail/base.go | 25 +- internal/tui/view/detail/detail_shared.go | 35 +- internal/tui/view/detail/music_detail.go | 180 ++++- internal/tui/view/detail/music_detail_test.go | 103 +++ internal/tui/view/home.go | 17 + internal/tui/view/media.go | 261 +++++-- internal/tui/view/media_test.go | 154 ++++ internal/tui/view/search_status.go | 58 +- internal/tui/widget/letterjump/letterjump.go | 61 ++ .../tui/widget/letterjump/letterjump_test.go | 20 + internal/tui/widget/playback/playback.go | 41 +- internal/tui/widget/poster/grid.go | 92 ++- internal/tui/widget/poster/item.go | 42 +- internal/tui/widget/poster/list.go | 7 +- internal/tui/widget/search/overlay.go | 91 ++- internal/tui/widget/settings/settings.go | 23 + internal/tui/widget/visualizer/visualizer.go | 133 ++++ .../tui/widget/visualizer/visualizer_test.go | 52 ++ internal/ui/alphabet.go | 106 +++ internal/ui/alphabet_test.go | 53 ++ internal/ui/layout.go | 11 +- internal/ui/mouse.go | 1 + internal/ui/overlay.go | 7 +- internal/ui/overlay_test.go | 20 + internal/ui/types.go | 8 +- 53 files changed, 2801 insertions(+), 666 deletions(-) create mode 100644 internal/config/auth.go create mode 100644 internal/config/auth_test.go create mode 100644 internal/config/music.go create mode 100644 internal/plex/artwork_auth_test.go create mode 100644 internal/search/live.go create mode 100644 internal/search/live_test.go create mode 100644 internal/tui/music_player.go create mode 100644 internal/tui/player/audio_integration_test.go create mode 100644 internal/tui/player/ipc.go create mode 100644 internal/tui/player/spectrum.go create mode 100644 internal/tui/player/spectrum_test.go create mode 100644 internal/tui/view/media_test.go create mode 100644 internal/tui/widget/letterjump/letterjump.go create mode 100644 internal/tui/widget/letterjump/letterjump_test.go create mode 100644 internal/tui/widget/visualizer/visualizer.go create mode 100644 internal/tui/widget/visualizer/visualizer_test.go create mode 100644 internal/ui/alphabet.go create mode 100644 internal/ui/alphabet_test.go create mode 100644 internal/ui/overlay_test.go diff --git a/README.md b/README.md index cc03ee0..e563597 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,22 @@ Movie and TV cover posters automatically use high-resolution images in Kitty See the [TUI guide](docs/content/tui.mdx) for configuration and playback details. Music libraries support artist → album → track navigation. Press **p** to play -the selected song; playing an artist or album starts its first playable track. +the selected song in the TUI, with a live spectrum visualizer and no extra +window. **v** chooses terminal audio or a separate window; **Ctrl+S → Music +Player** saves the default. Album covers appear in the artist gallery and beside +the track list. Scrolling automatically loads more artists. Playing an artist or +album starts its first playable track. + +Click the right-hand **A–Z rail**, or press **g then a letter**, to jump through +movies, TV shows, artists, albums, and track titles. Missing library pages load +automatically. **Home / End** reach the first/last item; **Page Up / Page Down** +move by a page. The library footer shows how many items have loaded. + +Use **Space** to pause, **[ / ]** to seek, **+ / -** for volume, **m** to mute, +**z** to cycle spectrum/mirror/off, and **x** to stop. The visualizer measures +the playing audio and uses ordinary terminal cells, including through tmux and +SSH. **/** searches Plex for artists, albums, and track names without requiring +a local index first. ### CLI (Command Line) `plexctl` provides a comprehensive command-line interface for automation and quick tasks. Every command supports the `-h` or `--help` flag for detailed usage instructions. diff --git a/cmd/search.go b/cmd/search.go index 4a9c3e5..e3eee01 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -1,9 +1,7 @@ package cmd import ( - "context" "fmt" - "os" "strings" "github.com/sahilm/fuzzy" @@ -23,15 +21,16 @@ var searchStatusCmd = &cobra.Command{ Short: "Show search index status and library statistics", RunE: func(cmd *cobra.Command, args []string) error { idx := search.GetIndex() + entries, indexedAt := idx.Snapshot() lastIndexed := "Never" - if !idx.LastIndexed.IsZero() { - lastIndexed = idx.LastIndexed.Format("2006-01-02 15:04:05") + if !indexedAt.IsZero() { + lastIndexed = indexedAt.Format("2006-01-02 15:04:05") } data := map[string]interface{}{ "last_indexed": lastIndexed, - "total_entries": len(idx.Entries), + "total_entries": len(entries), } return ui.OutputData{ @@ -39,7 +38,7 @@ var searchStatusCmd = &cobra.Command{ Headers: []string{"PROPERTY", "VALUE"}, Rows: [][]string{ {"Last Indexed", lastIndexed}, - {"Total Entries", fmt.Sprintf("%d", len(idx.Entries))}, + {"Total Entries", fmt.Sprintf("%d", len(entries))}, }, Raw: data, }.Print() @@ -51,26 +50,24 @@ var searchReindexCmd = &cobra.Command{ Short: "Rebuild the local search index", RunE: func(cmd *cobra.Command, args []string) error { progress := make(chan search.IndexProgress, 100) + finished := make(chan error, 1) idx := search.GetIndex() theme := ui.CurrentTheme() fmt.Println(ui.TitleStyle(theme).Render("Starting Reindex...")) go func() { - err := idx.Reindex(context.Background(), progress) - if err != nil { - fmt.Fprintf(os.Stderr, "\nError: %v\n", err) - } - progress <- search.IndexProgress{Message: "DONE"} + defer close(progress) + finished <- idx.Reindex(cmd.Context(), progress) }() for p := range progress { - if p.Message == "DONE" { - break - } fmt.Printf("\r\033[K[%d/%d] %s", p.Current, p.Total, p.Message) } fmt.Println() + if err := <-finished; err != nil { + return err + } fmt.Println(ui.SuccessStyle(theme).Render("Indexing complete!")) return nil }, @@ -83,12 +80,13 @@ var searchFindCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { query := strings.Join(args, " ") idx := search.GetIndex() + entries, _ := idx.Snapshot() - if len(idx.Entries) == 0 { + if len(entries) == 0 { return fmt.Errorf("index is empty. Please run 'plexctl search reindex' first") } - matches := fuzzy.FindFrom(query, entrySource(idx.Entries)) + matches := fuzzy.FindFrom(query, entrySource(entries)) if len(matches) == 0 { fmt.Println("No matches found.") @@ -103,7 +101,7 @@ var searchFindCmd = &cobra.Command{ if i >= 20 { break } - e := idx.Entries[match.Index] + e := entries[match.Index] rows = append(rows, []string{ e.RatingKey, e.Title, @@ -124,7 +122,7 @@ var searchFindCmd = &cobra.Command{ type entrySource []search.IndexEntry -func (s entrySource) String(i int) string { return s[i].Title } +func (s entrySource) String(i int) string { return s[i].SearchableText() } func (s entrySource) Len() int { return len(s) } func init() { diff --git a/cmd/tui.go b/cmd/tui.go index e7369f8..8b41355 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -16,6 +16,7 @@ import ( "github.com/ygelfand/plexctl/internal/termgraphics" "github.com/ygelfand/plexctl/internal/tui" "github.com/ygelfand/plexctl/internal/tui/artwork" + "github.com/ygelfand/plexctl/internal/tui/player" ) var tuiCmd = &cobra.Command{ @@ -64,6 +65,13 @@ func runTUI() error { slog.Debug("TUI: Initializing controller and program") p := tea.NewProgram(tui.NewController(result), tea.WithAltScreen(), tea.WithOutput(output), tea.WithMouseCellMotion()) + defer func() { + pm := player.GetPlayerManager() + if pm.Status().AudioOnly || cfg.CloseVideoOnQuit { + pm.StopPlayback()() + } + pm.Close() + }() if _, err := p.Run(); err != nil { slog.Error("TUI: Program run failed", "error", err) diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 33d70bb..370977d 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -33,8 +33,8 @@ mpv's Space, Right/Left, and q bindings, including any custom bindings you set. - **`u`**: Switch User (Plex Home). - **`p`**: Play selected item. -- **`v`**: Choose terminal playback: Automatic, Kitty, Sixel, or Text. -- **`ctrl+p`**: Stream the selected item in the terminal (Kitty, Sixel, or TCT). +- **`v`**: Choose a player: terminal/window for music; Automatic, Kitty, Sixel, or Text for video. +- **`ctrl+p`**: Play music in the TUI or stream video with the saved terminal renderer. - **`/`**: Open global fuzzy search. - **`ctrl+l`**: Open library configuration (show/hide/icon picker). - **`ctrl+s`**: Open global settings (theme/icon type). @@ -46,7 +46,15 @@ mpv's Space, Right/Left, and q bindings, including any custom bindings you set. ## Features ### Fuzzy Search -Pressing `/` opens a global fuzzy search overlay. It searches across all indexed libraries for titles, actors, and directors. Results are updated instantly as you type. +Press `/` to search artists, albums, tracks, movies, and TV. Local fuzzy matches +appear immediately; Plex server results follow as you type. Server search covers +the entire library, including artists you have not scrolled to, and works without +building a local index. Track results include their artist and album so you can +distinguish songs with the same name. Click a result or press Enter to open it. + +The optional local index also includes all music artists, albums, and tracks. +Rebuild an existing index from Search → Status → `r`, or run +`plexctl search reindex`, to make these available to `plexctl search find`. ### Library Configuration Pressing `ctrl+l` opens the configuration manager. Here you can: @@ -61,6 +69,54 @@ Music libraries open **artists → albums → tracks**. Click an item or press artist or album directly starts its first playable track; open the album to choose another song. **Esc** or right click returns to the previous level. +The artist page displays a grid of album covers with titles and years. Album +art also appears beside the track list after selecting an album. It uses native +high-resolution images in Kitty and text images elsewhere, and resizes with the pane. +Library scrolling loads additional artists automatically with either the mouse +wheel or keyboard, in both poster and list views. A failed page can be retried +with `r` without discarding the artists already loaded. + +Click the **A–Z rail** on the right, or press **`g` then a letter**, to jump through +movies, TV shows, music artists, an artist's albums, or album tracks. **`#`** jumps +to numbers and other characters. Plex sort titles are respected; accented Latin +names share their base letter. Albums are alphabetical; tracks retain album order +and jump by song title. The rail folds into columns in shorter panes. + +Library jumps fetch missing pages automatically and show the loaded/total count. +Press **Esc** to cancel a pending jump, **Home** for the first item, **End** for the +last item (loading remaining pages), or **Page Up / Page Down** to move by a page. +Fetched pages stay available for subsequent jumps while the TUI is open. + +Music plays in the current TUI by default, without opening an mpv window, even +when the file contains embedded cover art. You can keep browsing or searching +while listening. Press **`v`** to choose **In this terminal** or **Separate player +window**. **Ctrl+P** always chooses terminal music; **Ctrl+S → Music Player** saves +the default (`music_player: terminal` or `window` in the configuration). + +The music player includes a 16-band audio spectrum with a color gradient, smooth +attack and release, falling peaks, and stereo level meters. These are measured +from the audio being decoded by mpv. Pause and silence settle the display; seeking +updates the spectrum to the new audio position. It uses ordinary Unicode cells +and terminal colors, so it also works in tmux and over SSH without Kitty/Sixel, +desktop audio capture, or an extra visualizer program. Audio plays on the host +running mpv. The analysis uses stereo output; mpv downmixes multichannel music. + +| Control | Action | +| --- | --- | +| Space, or click the visualizer/title | Pause/resume | +| `[` / `]` | Seek backward/forward 10 seconds | +| Click the progress bar | Seek to that position | +| Wheel over the player | Seek | +| `+` / `-`, or Shift+wheel over the player | Adjust volume | +| `m` | Mute/unmute | +| `z` | Cycle spectrum, mirror, and compact controls | +| `x` | Stop | + +The visualizer shrinks with the terminal and can be disabled under **Ctrl+S → +Music Visualizer** (`music_visualizer: spectrum`, `mirror`, or `off`). It requires +mpv's FFmpeg/lavfi audio filters; music playback remains available in builds +without those filters. Terminal music stops when you quit the TUI. + When playback is started, a playback bar appears at the bottom showing: - Current progress and duration. - Play/Pause status. @@ -111,8 +167,8 @@ plexctl play 12345 --terminal-video sixel --no-resume ``` The preference is stored as `terminal_video` in `~/.plexctl.yaml` and can also be -set with `PLEXCTL_TERMINAL_VIDEO`. Plain `p` and `plexctl play 12345` still open a -video window. The existing `plexctl play 12345 --tct` explicitly selects TCT. +set with `PLEXCTL_TERMINAL_VIDEO`. Plain `p` on a video and `plexctl play 12345` +open a video window. The existing `plexctl play 12345 --tct` explicitly selects TCT. Kitty output uses escape-code transport, so it does not require shared memory with the terminal when using SSH. With tmux 3.3+, plexctl temporarily enables @@ -129,7 +185,7 @@ for terminal requirements and configuration. ### High-resolution cover posters In Kitty 0.28+, using Unicode image placeholders, movie -and TV posters automatically use native images, retaining up to 768 pixels on +and TV posters and album covers automatically use native images, retaining up to 768 pixels on their longest side instead of reducing the cover to 15 colored text cells. This works in grids, details, and through tmux. Posters scroll and clip with the TUI and are restored after video playback. Other terminals retain text posters. diff --git a/go.mod b/go.mod index f96837c..16e85c6 100644 --- a/go.mod +++ b/go.mod @@ -93,7 +93,7 @@ require ( golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.37.0 golang.org/x/tools v0.44.0 // indirect ) diff --git a/internal/config/auth.go b/internal/config/auth.go new file mode 100644 index 0000000..7e4d049 --- /dev/null +++ b/internal/config/auth.go @@ -0,0 +1,14 @@ +package config + +import "os" + +// ServerToken selects the same credentials for metadata, artwork, and streams. +func (c *Config) ServerToken() string { + if token := os.Getenv("PLEXCTL_TOKEN"); token != "" { + return token + } + if c.HomeUser.AccessToken != "" { + return c.HomeUser.AccessToken + } + return c.Token +} diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go new file mode 100644 index 0000000..46c512f --- /dev/null +++ b/internal/config/auth_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +func TestServerTokenPrecedence(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "environment") + cfg := Config{Token: "account"} + cfg.HomeUser.AccessToken = "home-user" + if cfg.ServerToken() != "environment" { + t.Fatal("environment token did not take precedence") + } + t.Setenv("PLEXCTL_TOKEN", "") + if cfg.ServerToken() != "home-user" { + t.Fatal("Home user token did not take precedence over account token") + } + cfg.HomeUser.AccessToken = "" + if cfg.ServerToken() != "account" { + t.Fatal("account token fallback failed") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 71f7289..0c9dcb9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -114,6 +114,8 @@ type Config struct { AutoHomeLogin bool `mapstructure:"auto_home_login"` CloseVideoOnQuit bool `mapstructure:"close_video_on_quit"` TerminalVideo VideoOutput `mapstructure:"terminal_video"` + MusicPlayer string `mapstructure:"music_player"` + MusicVisualizer string `mapstructure:"music_visualizer"` // Server management DefaultServer string `mapstructure:"default_server"` // Stores the ClientIdentifier @@ -151,6 +153,8 @@ func Get() *Config { AutoHomeLogin: true, DefaultViewMode: ViewModePoster, TerminalVideo: VideoAuto, + MusicPlayer: "terminal", + MusicVisualizer: "spectrum", } }) return instance @@ -217,6 +221,8 @@ func (c *Config) Save() error { viper.Set("auto_home_login", c.AutoHomeLogin) viper.Set("close_video_on_quit", c.CloseVideoOnQuit) viper.Set("terminal_video", c.TerminalVideo) + viper.Set("music_player", c.MusicPlayer) + viper.Set("music_visualizer", c.MusicVisualizer) viper.Set("cache_dir", c.CacheDir) viper.Set("default_server", c.DefaultServer) viper.Set("servers", c.Servers) diff --git a/internal/config/music.go b/internal/config/music.go new file mode 100644 index 0000000..28db5c5 --- /dev/null +++ b/internal/config/music.go @@ -0,0 +1,12 @@ +package config + +func (c *Config) MusicOutput() VideoOutput { + if c.MusicPlayer == "window" { + return VideoWindow + } + return AudioTerminal +} + +func IsMusic(mediaType string) bool { + return mediaType == "artist" || mediaType == "album" || mediaType == "track" +} diff --git a/internal/config/video.go b/internal/config/video.go index 13758fd..7ec8550 100644 --- a/internal/config/video.go +++ b/internal/config/video.go @@ -11,6 +11,8 @@ const ( VideoKitty VideoOutput = "kitty" VideoSixel VideoOutput = "sixel" VideoTCT VideoOutput = "tct" + // AudioTerminal keeps the TUI active and disables mpv's video window. + AudioTerminal VideoOutput = "audio" ) // ParseTerminalVideo validates a terminal renderer preference. diff --git a/internal/plex/artwork_auth_test.go b/internal/plex/artwork_auth_test.go new file mode 100644 index 0000000..bbcaf55 --- /dev/null +++ b/internal/plex/artwork_auth_test.go @@ -0,0 +1,32 @@ +package plex + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestArtworkUsesHomeUserToken(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("X-Plex-Token") != "home+user&token" { + http.Error(w, "wrong credentials", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte("album artwork")) + })) + defer server.Close() + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.Token, cfg.HomeUser.AccessToken = "account-token", "home+user&token" + cfg.DefaultServer, cfg.CacheDir = "artwork-token-test", t.TempDir() + cfg.Servers = map[string]config.Server{"artwork-token-test": {URL: server.URL}} + data, err := GetImage(context.Background(), "/album.png?width=720") + if err != nil || string(data) != "album artwork" { + t.Fatalf("Home user artwork failed: %v", err) + } +} diff --git a/internal/plex/client.go b/internal/plex/client.go index dbbf59f..e48a6d3 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -3,10 +3,8 @@ package plex import ( "context" "fmt" - "log/slog" "net/http" "net/http/httputil" - "os" "runtime" "time" @@ -59,23 +57,7 @@ func (t *loggingTransport) verbosityBody() bool { } func NewClient() (*Client, error) { - cfg := config.Get() - - // 1. Check for Token (Global) - token := os.Getenv("PLEXCTL_TOKEN") - source := "env" - if token == "" { - token = cfg.Token - source = "main account" - // If a home user access token is set, it overrides the main account token - if cfg.HomeUser.AccessToken != "" { - token = cfg.HomeUser.AccessToken - source = "home user access" - } - } - - slog.Debug("NewClient: Initializing with token", "source", source) - return NewClientWithToken(token) + return NewClientWithToken(config.Get().ServerToken()) } // NewHomeUserClient specifically uses the V2 AuthToken (general user token) diff --git a/internal/plex/media.go b/internal/plex/media.go index b02a5e2..946a857 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + neturl "net/url" "strings" "time" @@ -131,8 +132,11 @@ func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, if strings.Contains(url, "?") { separator = "&" } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, cfg.Token) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(cfg.ServerToken())) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) @@ -186,8 +190,12 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { if strings.Contains(url, "?") { separator = "&" } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, cfg.Token) - resp, err := http.Get(url) + url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(cfg.ServerToken())) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } diff --git a/internal/plex/playable_test.go b/internal/plex/playable_test.go index c2beb34..fb0e06b 100644 --- a/internal/plex/playable_test.go +++ b/internal/plex/playable_test.go @@ -13,6 +13,7 @@ import ( ) func TestResolveMusicPlayback(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "music-test-token") track := components.Metadata{RatingKey: ptr("track"), Type: "track", Title: "Song", Media: []components.Media{ {}, // Plex can list an unavailable version before a playable one. {Part: []components.Part{{}, {Key: "/music.flac"}}}, @@ -21,6 +22,11 @@ func TestResolveMusicPlayback(t *testing.T) { album := components.Metadata{RatingKey: ptr("album"), Type: "album"} empty := components.Metadata{RatingKey: ptr("empty"), Type: "album"} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("X-Plex-Token") != "music-test-token" && r.Header.Get("X-Plex-Token") != "music-test-token" { + t.Error("music request did not use the configured token") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } w.Header().Set("Content-Type", "application/json") var items []components.Metadata switch r.URL.Path { diff --git a/internal/search/index.go b/internal/search/index.go index 5e079ca..e382527 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -4,9 +4,12 @@ import ( "context" "fmt" "log/slog" + "slices" + "strings" "sync" "time" + "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" "github.com/ygelfand/plexctl/internal/cache" "github.com/ygelfand/plexctl/internal/config" @@ -20,6 +23,8 @@ type IndexEntry struct { Type string `json:"type"` SectionID string `json:"sectionId"` Library string `json:"library"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` // Additional searchable fields OriginalTitle string `json:"originalTitle,omitempty"` Summary string `json:"summary,omitempty"` @@ -32,6 +37,31 @@ type SearchIndex struct { LastIndexed time.Time `json:"lastIndexed"` Entries []IndexEntry `json:"entries"` mu sync.RWMutex + reindexMu sync.Mutex +} + +// SearchableText includes music context, so an album or track can also be found +// by artist. Titles remain first for the fuzzy matcher's ranking. +func (e IndexEntry) SearchableText() string { + return strings.Join([]string{e.Title, e.Artist, e.Album, e.OriginalTitle, + strings.Join(e.Cast, " "), strings.Join(e.Directors, " ")}, " ") +} + +func (e IndexEntry) Description() string { + parts := []string{strings.ToUpper(e.Type)} + for _, part := range []string{e.Artist, e.Album, e.Library} { + if part != "" && part != e.Title { + parts = append(parts, part) + } + } + return strings.Join(parts, " · ") +} + +// Snapshot returns an immutable view of the last completed index. +func (idx *SearchIndex) Snapshot() ([]IndexEntry, time.Time) { + idx.mu.RLock() + defer idx.mu.RUnlock() + return slices.Clone(idx.Entries), idx.LastIndexed } var ( @@ -90,6 +120,8 @@ type IndexProgress struct { } func (idx *SearchIndex) Reindex(ctx context.Context, progress chan<- IndexProgress) error { + idx.reindexMu.Lock() + defer idx.reindexMu.Unlock() client, err := plex.NewClient() if err != nil { return err @@ -114,16 +146,15 @@ func (idx *SearchIndex) Reindex(ctx context.Context, progress chan<- IndexProgre } slog.Debug("SearchIndex: indexing library", "title", *lib.Title, "index", i+1, "total", totalLibs) - progress <- IndexProgress{ + notifyProgress(progress, IndexProgress{ Current: i + 1, Total: totalLibs, Message: fmt.Sprintf("Indexing %s...", *lib.Title), - } + }) entries, err := idx.indexLibrary(ctx, client, *lib.Key, *lib.Title, i+1, totalLibs, progress) if err != nil { - slog.Error("SearchIndex: failed to index library", "library", *lib.Title, "error", err) - continue + return fmt.Errorf("index %s: %w", *lib.Title, err) } newEntries = append(newEntries, entries...) } @@ -134,11 +165,11 @@ func (idx *SearchIndex) Reindex(ctx context.Context, progress chan<- IndexProgre idx.mu.Unlock() slog.Debug("SearchIndex: reindex complete", "total_entries", len(newEntries)) - progress <- IndexProgress{ + notifyProgress(progress, IndexProgress{ Current: totalLibs, Total: totalLibs, Message: "Finalizing...", - } + }) return idx.Save() } @@ -147,6 +178,7 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s var entries []IndexEntry start := 0 size := 100 + music := false for { slog.Log(context.Background(), config.LevelTrace, "SearchIndex: fetching page", "library", libTitle, "start", start, "size", size) @@ -179,35 +211,14 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s for j, meta := range metadata { currentPos := start + j + 1 - progress <- IndexProgress{ + notifyProgress(progress, IndexProgress{ Current: libIdx, Total: libTotal, Message: fmt.Sprintf("%s: %d/%d", libTitle, currentPos, totalInLib), - } - - entry := IndexEntry{ - RatingKey: ui.PtrToString(meta.RatingKey), - Title: meta.Title, - Type: meta.Type, - SectionID: sectionID, - Library: libTitle, - Summary: ui.PtrToString(meta.Summary), - } - if meta.OriginalTitle != nil { - entry.OriginalTitle = *meta.OriginalTitle - } - if meta.Year != nil { - entry.Year = int(*meta.Year) - } + }) - for _, r := range meta.Role { - entry.Cast = append(entry.Cast, r.Tag) - } - for _, d := range meta.Director { - entry.Directors = append(entry.Directors, d.Tag) - } - - entries = append(entries, entry) + entries = append(entries, entryFromMetadata(meta, sectionID, libTitle)) + music = music || meta.Type == "artist" if meta.Type == "show" && meta.RatingKey != nil { seasons, _ := plex.GetChildren(ctx, *meta.RatingKey) @@ -239,11 +250,76 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s } } - start += size + start += len(metadata) if mc.TotalSize != nil && int64(start) >= *mc.TotalSize { break } } + if music { + // Plex's actual library type IDs are album=9 and track=10. The pinned + // SDK's enum uses incorrect music IDs; explicitly use the wire values. + for _, mediaType := range []components.MediaType{9, 10} { + for start := 0; ; { + res, err := client.SDK.Content.ListContent(ctx, operations.ListContentRequest{ + SectionID: sectionID, XPlexContainerStart: ui.Ptr(start), XPlexContainerSize: ui.Ptr(size), + MediaQuery: &components.MediaQuery{Type: &mediaType}, + }) + if err != nil { + return nil, err + } + if res.MediaContainerWithMetadata == nil || res.MediaContainerWithMetadata.MediaContainer == nil { + return nil, fmt.Errorf("missing music metadata") + } + mc := res.MediaContainerWithMetadata.MediaContainer + if len(mc.Metadata) == 0 { + break + } + for _, meta := range mc.Metadata { + entries = append(entries, entryFromMetadata(meta, sectionID, libTitle)) + } + start += len(mc.Metadata) + notifyProgress(progress, IndexProgress{Current: libIdx, Total: libTotal, + Message: fmt.Sprintf("%s: %d music entries", libTitle, len(entries))}) + if mc.TotalSize != nil && int64(start) >= *mc.TotalSize { + break + } + } + } + } + return entries, nil } + +func notifyProgress(progress chan<- IndexProgress, update IndexProgress) { + // A hidden status tab must not block indexing. Progress is informational. + select { + case progress <- update: + default: + } +} + +func entryFromMetadata(meta components.Metadata, sectionID, library string) IndexEntry { + entry := IndexEntry{ + RatingKey: ui.PtrToString(meta.RatingKey), Title: meta.Title, Type: meta.Type, + SectionID: sectionID, Library: library, Summary: ui.PtrToString(meta.Summary), + OriginalTitle: ui.PtrToString(meta.OriginalTitle), + } + if meta.Year != nil { + entry.Year = int(*meta.Year) + } + switch meta.Type { + case "album": + entry.Artist = ui.PtrToString(meta.ParentTitle) + case "track": + entry.Artist = ui.PtrToString(meta.GrandparentTitle) + entry.Album = ui.PtrToString(meta.ParentTitle) + } + for _, role := range meta.Role { + entry.Cast = append(entry.Cast, role.Tag) + } + for _, director := range meta.Director { + entry.Directors = append(entry.Directors, director.Tag) + } + return entry +} diff --git a/internal/search/live.go b/internal/search/live.go new file mode 100644 index 0000000..006e5a2 --- /dev/null +++ b/internal/search/live.go @@ -0,0 +1,57 @@ +package search + +import ( + "context" + "fmt" + "strings" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/ui" +) + +// Live searches the server's complete library, including music that has never +// been paged into the TUI or indexed locally. +func Live(ctx context.Context, query string) ([]IndexEntry, error) { + if strings.TrimSpace(query) == "" { + return nil, nil + } + client, err := plex.NewClient() + if err != nil { + return nil, err + } + response, err := client.SDK.Search.SearchHubs(ctx, operations.SearchHubsRequest{ + Query: query, Limit: ui.Ptr(int64(50)), + }) + if err != nil { + return nil, err + } + if response.Object == nil || response.Object.MediaContainer == nil { + return nil, fmt.Errorf("search returned no media container") + } + entries := []IndexEntry{} + seen := map[string]bool{} + for _, hub := range response.Object.MediaContainer.Hub { + for _, meta := range hub.Metadata { + switch meta.Type { + case "artist", "album", "track", "movie", "show", "season", "episode": + default: + continue + } + key := ui.PtrToString(meta.RatingKey) + if key == "" || seen[key] { + continue + } + seen[key] = true + sectionID, library := "", "" + if value, ok := meta.AdditionalProperties["librarySectionID"]; ok { + sectionID = fmt.Sprint(value) + } + if value, ok := meta.AdditionalProperties["librarySectionTitle"].(string); ok { + library = value + } + entries = append(entries, entryFromMetadata(meta, sectionID, library)) + } + } + return entries, nil +} diff --git a/internal/search/live_test.go b/internal/search/live_test.go new file mode 100644 index 0000000..8af3811 --- /dev/null +++ b/internal/search/live_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestMusicSearchAndIndexIncludeEveryPage(t *testing.T) { + counts := map[string]int{"": 137, "9": 113, "10": 205} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var container map[string]any + switch r.URL.Path { + case "/library/sections/all": + container = map[string]any{"Directory": []any{map[string]any{"key": "3", "title": "Music", "type": "artist"}}} + case "/library/sections/3/all": + typ := r.URL.Query().Get("type") + startText := r.Header.Get("X-Plex-Container-Start") + if startText == "" { + startText = r.URL.Query().Get("X-Plex-Container-Start") + } + start, _ := strconv.Atoi(startText) + count, ok := counts[typ] + if !ok { + t.Errorf("wrong Plex music type: %q", typ) + } + // Deliberately return smaller pages than requested. + items := []any{} + for i := start; i < min(start+37, count); i++ { + items = append(items, map[string]any{ + "ratingKey": fmt.Sprintf("%s-%d", typ, i), "type": map[string]string{"": "artist", "9": "album", "10": "track"}[typ], + "title": fmt.Sprintf("Title %d", i), "parentTitle": "Album Name", "grandparentTitle": "Artist Name", + }) + } + container = map[string]any{"Metadata": items, "totalSize": count} + case "/hubs/search": + if r.URL.Query().Get("query") != "hidden & finale" { + t.Error("query was not encoded correctly") + } + container = map[string]any{"Hub": []any{map[string]any{"type": "track", "Metadata": []any{map[string]any{ + "ratingKey": "hidden", "title": "Hidden Finale", "type": "track", "parentTitle": "Album Name", + "grandparentTitle": "Artist Name", "librarySectionID": 3, + }}}}} + default: + t.Errorf("unexpected request: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": container}) + })) + defer server.Close() + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + t.Setenv("PLEXCTL_TOKEN", "qa") + cfg.DefaultServer, cfg.CacheDir = "search-test", t.TempDir() + cfg.Servers = map[string]config.Server{"search-test": {URL: server.URL}} + + entries, err := Live(context.Background(), "hidden & finale") + if err != nil || len(entries) != 1 || entries[0].SectionID != "3" || entries[0].Artist != "Artist Name" { + t.Fatalf("live music search: %+v, %v", entries, err) + } + var index SearchIndex + if err := index.Reindex(context.Background(), nil); err != nil { + t.Fatal(err) + } + entries, indexedAt := index.Snapshot() + if len(entries) != 455 || indexedAt.IsZero() { + t.Fatalf("index has %d entries; want all 455 artists, albums, and tracks", len(entries)) + } + last := entries[len(entries)-1] + if last.Type != "track" || !strings.Contains(last.SearchableText(), "Artist Name Album Name") { + t.Fatalf("track cannot be found by artist/album: %+v", last) + } +} diff --git a/internal/tui/music_player.go b/internal/tui/music_player.go new file mode 100644 index 0000000..94d6868 --- /dev/null +++ b/internal/tui/music_player.go @@ -0,0 +1,171 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/tui/widget/visualizer" + "github.com/ygelfand/plexctl/internal/ui" +) + +func (c *Controller) visualHeight(height int) int { + if config.Get().MusicVisualizer == "off" || height < 20 { + return 0 + } + return max(2, min(6, (height-16)/4)) +} + +func (c *Controller) playerHeight(height int) int { + if !c.playerStatus.AudioOnly { + return 4 + } + if visualHeight := c.visualHeight(height); visualHeight > 0 { + return 6 + visualHeight + } + return 5 +} + +func (c *Controller) refreshPlayer() tea.Cmd { + previous := c.playerStatus + c.playerStatus = c.player.Status() + if previous.Key != c.playerStatus.Key { + c.visualizer = visualizer.Model{} + } + now := time.Now() + elapsed := now.Sub(c.visualTick) + c.visualTick = now + c.visualizer.Step(c.playerStatus.Spectrum, c.playerStatus.Paused(), elapsed) + if previous.Active() != c.playerStatus.Active() || previous.AudioOnly != c.playerStatus.AudioOnly { + return resizePlayer() + } + return nil +} + +func resizePlayer() tea.Cmd { + layout := ui.GetLayout() + width, height := layout.TotalWidth(), layout.TotalHeight() + return func() tea.Msg { return tea.WindowSizeMsg{Width: width, Height: height} } +} + +func (c *Controller) musicControl(key string) tea.Cmd { + switch key { + case "[": + return c.player.Seek(-10) + case "]": + return c.player.Seek(10) + case "+", "=": + return c.player.Volume(5) + case "-": + return c.player.Volume(-5) + case "m": + return c.player.ToggleMute() + case "z": + if c.playerStatus.AudioOnly { + cfg := config.Get() + switch cfg.MusicVisualizer { + case "mirror": + cfg.MusicVisualizer = "off" + case "off": + cfg.MusicVisualizer = "spectrum" + default: + cfg.MusicVisualizer = "mirror" + } + _ = cfg.Save() + return resizePlayer() + } + } + return nil +} + +func (c *Controller) musicControls() string { + volume := fmt.Sprintf("%.0f%%", c.playerStatus.Volume) + if c.playerStatus.Muted { + volume = "MUTED" + } + if ui.GetLayout().InnerWidth() < 75 { + return "[space] | [-] " + volume + " | [+] | [m] | [z] | [x]" + } + return "[space] Pause | [-] " + volume + " | [+] | [m] Mute | [z] Visuals | [x] Stop" +} + +func (c *Controller) renderMusicPlayer() string { + layout := ui.GetLayout() + width := max(1, layout.InnerWidth()) + accent := ui.Accent(c.theme) + state := "PLAYING" + if c.playerStatus.Paused() { + state = "PAUSED" + } + state = lipgloss.NewStyle().Foreground(c.theme.BrightCyan()).Render(state) + title := lipgloss.NewStyle().Bold(true).Foreground(c.theme.BrightYellow()). + Render(ui.Ellipsis(c.playerStatus.Title, max(1, width-10))) + title += strings.Repeat(" ", max(1, width-lipgloss.Width(title)-lipgloss.Width(state))) + state + lines := []string{ui.Ellipsis(title, width)} + if height := c.visualHeight(layout.TotalHeight()); height > 0 { + mode := config.Get().MusicVisualizer + if mode != "mirror" { + mode = "spectrum" + } + lines = append(lines, c.visualizer.View(width, height, mode)) + } + lines = append(lines, c.musicProgress(width), ui.Ellipsis(c.musicControls(), width)) + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent). + Padding(0, 1).Width(width + 2).Render(strings.Join(lines, "\n")) +} + +func (c *Controller) musicProgress(width int) string { + clock := fmt.Sprintf("%s / %s", ui.FormatDuration(int(c.playerStatus.Time*1000)), + ui.FormatDuration(int(c.playerStatus.Duration*1000))) + barWidth := max(1, width-len(clock)-2) + progress := 0.0 + if c.playerStatus.Duration > 0 { + progress = max(0, min(1, c.playerStatus.Time/c.playerStatus.Duration)) + } + filled := min(barWidth-1, int(progress*float64(barWidth))) + bar := lipgloss.NewStyle().Foreground(ui.Accent(c.theme)).Render(strings.Repeat("━", filled)+"●") + + lipgloss.NewStyle().Foreground(c.theme.BrightBlack()).Render(strings.Repeat("─", barWidth-filled-1)) + return ui.Ellipsis(bar+" "+clock, width) +} + +func (c *Controller) playerMouse(mouse tea.MouseMsg, top int) tea.Cmd { + if delta := ui.WheelDelta(mouse); delta != 0 { + if mouse.Shift { + return c.player.Volume(float64(-delta * 5)) + } + return c.player.Seek(float64(delta * 10)) + } + if !ui.LeftClick(mouse) { + return nil + } + if !c.playerStatus.AudioOnly { + return c.player.TogglePause() + } + view := c.renderMusicPlayer() + local := mouse + local.X -= ui.SidebarTotalWidth + local.Y -= top + progressRow := lipgloss.Height(view) - 3 + if local.Y == progressRow { + clock := fmt.Sprintf("%s / %s", ui.FormatDuration(int(c.playerStatus.Time*1000)), + ui.FormatDuration(int(c.playerStatus.Duration*1000))) + barWidth := max(1, ui.GetLayout().InnerWidth()-len(clock)-2) + return c.player.SeekPercent(float64(local.X-2) / float64(max(1, barWidth-1)) * 100) + } + if local.Y == progressRow+1 { + if key := ui.ShortcutAt(view, local); key != "" { + switch key { + case "space": + return c.player.TogglePause() + case "x": + return c.player.StopPlayback() + default: + return c.musicControl(key) + } + } + } + return c.player.TogglePause() +} diff --git a/internal/tui/navigator.go b/internal/tui/navigator.go index 1062f5e..692bb69 100644 --- a/internal/tui/navigator.go +++ b/internal/tui/navigator.go @@ -40,6 +40,9 @@ func (n *Navigator) Push(o Overlay) tea.Cmd { func (n *Navigator) Pop() { if len(n.overlays) > 0 { + if closer, ok := n.ActiveOverlay().(interface{ Close() }); ok { + closer.Close() + } n.overlays = n.overlays[:len(n.overlays)-1] } } diff --git a/internal/tui/player/actions.go b/internal/tui/player/actions.go index 0226f69..4f70b47 100644 --- a/internal/tui/player/actions.go +++ b/internal/tui/player/actions.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net/url" "strings" "time" @@ -30,7 +31,8 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video if strings.Contains(part.Key, "?") { separator = "&" } - playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, part.Key, separator, cfg.Token) + token := url.QueryEscape(cfg.ServerToken()) + playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, part.Key, separator, token) var subtitles []ExternalSubtitle for _, stream := range part.Stream { @@ -39,7 +41,7 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video if strings.Contains(stream.Key, "?") { sep = "&" } - subURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, stream.Key, sep, cfg.Token) + subURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, stream.Key, sep, token) lang := "" if stream.LanguageCode != nil { lang = *stream.LanguageCode @@ -59,6 +61,9 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video } title := metadata.Title + if metadata.Type == "track" && metadata.GrandparentTitle != nil { + title = *metadata.GrandparentTitle + " — " + title + } if noReport { title = "Trailer: " + title } diff --git a/internal/tui/player/audio_integration_test.go b/internal/tui/player/audio_integration_test.go new file mode 100644 index 0000000..3082269 --- /dev/null +++ b/internal/tui/player/audio_integration_test.go @@ -0,0 +1,170 @@ +//go:build integration && !windows + +package player + +import ( + "bytes" + "encoding/binary" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/config" +) + +func testAudio(t *testing.T, dir string) string { + t.Helper() + const rate, frames = 48000, 48000 * 6 + var wav bytes.Buffer + wav.WriteString("RIFF") + _ = binary.Write(&wav, binary.LittleEndian, uint32(36+frames*4)) + wav.WriteString("WAVEfmt ") + for _, value := range []any{uint32(16), uint16(1), uint16(2), uint32(rate), uint32(rate * 4), uint16(4), uint16(16)} { + _ = binary.Write(&wav, binary.LittleEndian, value) + } + wav.WriteString("data") + _ = binary.Write(&wav, binary.LittleEndian, uint32(frames*4)) + for i := range frames { + frequency := 200.0 + if i >= rate*2 { + frequency = 4000 + } + value := int16(14000 * math.Sin(2*math.Pi*frequency*float64(i)/rate)) + if i >= rate*4 { + value = 0 + } + _ = binary.Write(&wav, binary.LittleEndian, value) + _ = binary.Write(&wav, binary.LittleEndian, value/2) + } + path := filepath.Join(dir, "tones.wav") + if err := os.WriteFile(path, wav.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func waitAudio(t *testing.T, pm *PlayerManager, label string, condition func(PlayerStatus) bool) PlayerStatus { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + status := pm.Status() + if condition(status) { + return status + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("%s: status %+v", label, pm.Status()) + return PlayerStatus{} +} + +func TestAudioPlaybackAndSpectrum(t *testing.T) { + mpv, err := exec.LookPath("mpv") + if err != nil { + t.Skip("mpv is not installed") + } + dir, err := os.MkdirTemp("", "plexctl-audio-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + media := testAudio(t, dir) + socket := filepath.Join(dir, "ipc") + cmd := exec.Command(mpv, "--no-config", "--ao=null", "--idle=yes", "--vid=no", + "--force-window=no", "--input-ipc-server="+socket) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(socket); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + pm := newPlayerManager(socket) + defer pm.Close() + defer pm.StopPlayback()() + if msg := pm.Play(media, "Audio QA", "qa", true, config.AudioTerminal, 0, nil)(); msg != nil { + t.Fatalf("play: %v", msg) + } + status := waitAudio(t, pm, "audio starts", func(s PlayerStatus) bool { return s.Time > 0.2 && s.Spectrum.Valid }) + if !status.AudioOnly || status.Spectrum.Left <= status.Spectrum.Right+5 { + t.Fatal("stereo levels did not preserve the source's 6 dB channel difference") + } + peak := func(s PlayerStatus) int { + band := 0 + for i, value := range s.Spectrum.Bands { + if value > s.Spectrum.Bands[band] { + band = i + } + } + return band + } + if band := peak(status); band != 3 && band != 4 { + t.Fatalf("200 Hz tone appeared in band %d", band) + } + pm.ioMu.Lock() + var window, video bool + _ = pm.conn.get("force-window", &window) + _ = pm.conn.get("vo-configured", &video) + pm.ioMu.Unlock() + if window || video { + t.Fatal("terminal music enabled a video window") + } + pm.TogglePause()() + paused := waitAudio(t, pm, "pause", func(s PlayerStatus) bool { return s.Paused() }) + time.Sleep(150 * time.Millisecond) + if math.Abs(pm.Status().Time-paused.Time) > 0.01 { + t.Fatal("paused playback kept advancing") + } + pm.Volume(-10)() + waitAudio(t, pm, "volume", func(s PlayerStatus) bool { return s.Volume == 90 }) + pm.ToggleMute()() + waitAudio(t, pm, "mute", func(s PlayerStatus) bool { return s.Muted }) + pm.SeekPercent(50)() + pm.TogglePause()() + waitAudio(t, pm, "seek updates frequencies", func(s PlayerStatus) bool { + return s.Time >= 3 && s.Spectrum.Valid && peak(s) == 10 + }) + pm.SeekPercent(85)() + waitAudio(t, pm, "silence", func(s PlayerStatus) bool { + return s.Time >= 5 && s.Spectrum.Valid && s.Spectrum.Left < -90 + }) + pm.StopPlayback()() + stopped := pm.Status() + if stopped.Active() || stopped.Spectrum.Valid { + t.Fatal("stop left audio or visualization active") + } +} + +func TestSpectrumFilterPreservesStereoSamples(t *testing.T) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg is not installed") + } + media := testAudio(t, t.TempDir()) + graph := strings.TrimSuffix(strings.TrimPrefix(spectrumFilter(), "@plexctl-spectrum:lavfi=["), "]") + decode := func(filter string) []byte { + args := []string{"-v", "error", "-i", media} + if filter != "" { + args = append(args, "-af", filter) + } + args = append(args, "-f", "s16le", "pipe:1") + cmd := exec.Command(ffmpeg, args...) + var errors bytes.Buffer + cmd.Stderr = &errors + output, err := cmd.Output() + if err != nil { + t.Fatalf("decode: %v: %s", err, errors.String()) + } + return output + } + if !bytes.Equal(decode(""), decode(graph)) { + t.Fatal("spectrum analysis changed the audible stereo samples") + } +} diff --git a/internal/tui/player/ipc.go b/internal/tui/player/ipc.go new file mode 100644 index 0000000..df7679f --- /dev/null +++ b/internal/tui/player/ipc.go @@ -0,0 +1,63 @@ +package player + +import ( + "encoding/json" + "fmt" + "net" + "time" +) + +// ipcClient is owned by PlayerManager's command loop. Every request has a +// deadline: a dead player cannot hang a control, shutdown, or the visualizer. +type ipcClient struct { + conn net.Conn + encoder *json.Encoder + decoder *json.Decoder + nextID uint64 +} + +func openIPC(socket string) (*ipcClient, error) { + conn, err := dialTerminal(socket) + if err != nil { + return nil, err + } + return &ipcClient{conn: conn, encoder: json.NewEncoder(conn), decoder: json.NewDecoder(conn)}, nil +} + +func (c *ipcClient) command(command any, result any) error { + c.nextID++ + if err := c.conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + return err + } + if err := c.encoder.Encode(map[string]any{"command": command, "request_id": c.nextID}); err != nil { + return err + } + for { + var reply struct { + ID uint64 `json:"request_id"` + Error string `json:"error"` + Data json.RawMessage `json:"data"` + } + if err := c.decoder.Decode(&reply); err != nil { + return err + } + if reply.ID != c.nextID { + continue + } + if reply.Error != "success" { + return fmt.Errorf("mpv: %s", reply.Error) + } + if result != nil && len(reply.Data) > 0 { + return json.Unmarshal(reply.Data, result) + } + return nil + } +} + +func (c *ipcClient) call(args ...any) error { + return c.command(args, nil) +} + +func (c *ipcClient) get(property string, result any) error { + return c.command([]any{"get_property", property}, result) +} diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index bb27702..8ff6a4e 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -3,28 +3,32 @@ package player import ( "context" "fmt" - "log/slog" "os" "os/exec" "path/filepath" "runtime" "sync" - "syscall" "time" "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" - "github.com/dexterlb/mpvipc" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" ) type PlayerManager struct { - conn *mpvipc.Connection + // ioMu serializes commands and lifecycle changes. Rendering only takes + // statusMu, so a slow IPC request never blocks the terminal UI. + ioMu sync.Mutex + conn *ipcClient + cancel context.CancelFunc socketPath string + statusMu sync.RWMutex status PlayerStatus - stopChan chan struct{} updates chan tea.Msg + done chan struct{} + closeOnce sync.Once + workers sync.WaitGroup } var ( @@ -34,476 +38,388 @@ var ( func GetPlayerManager() *PlayerManager { once.Do(func() { - cfg := config.Get() - socketPath := filepath.Join(cfg.CacheDir, "mpv.sock") + socket := filepath.Join(config.Get().CacheDir, "mpv.sock") if runtime.GOOS == "windows" { - socketPath = `\\.\pipe\mpv-socket` + socket = "\\\\.\\pipe\\mpv-socket" } else { - _ = os.MkdirAll(filepath.Dir(socketPath), 0o755) - } - - instance = &PlayerManager{ - socketPath: socketPath, - updates: make(chan tea.Msg, 10), + _ = os.MkdirAll(filepath.Dir(socket), 0o755) } + instance = newPlayerManager(socket) }) return instance } -// Lifecycle Methods - -func (pm *PlayerManager) VerifyConnection() bool { - if pm.conn == nil { - return false - } - // Try to get a basic property with a short timeout to ensure mpv is "real" - done := make(chan bool, 1) - go func() { - _, err := pm.conn.Call("get_property", "mpv-version") - done <- err == nil - }() - - select { - case ok := <-done: - return ok - case <-time.After(200 * time.Millisecond): - slog.Debug("PlayerManager: connection check timed out") - return false - } +func newPlayerManager(socket string) *PlayerManager { + return &PlayerManager{socketPath: socket, updates: make(chan tea.Msg, 1), done: make(chan struct{})} } func (pm *PlayerManager) Status() PlayerStatus { + pm.statusMu.RLock() + defer pm.statusMu.RUnlock() return pm.status } -func (pm *PlayerManager) Play(url, title, ratingKey string, noReport bool, output config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { - if output != config.VideoWindow { +func (pm *PlayerManager) setStatus(status PlayerStatus) { + pm.statusMu.Lock() + pm.status = status + pm.statusMu.Unlock() +} + +func (pm *PlayerManager) VerifyConnection() bool { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + return pm.conn != nil && pm.conn.get("mpv-version", nil) == nil +} + +func (pm *PlayerManager) Play( + url, title, ratingKey string, noReport bool, output config.VideoOutput, + startOffset int64, subtitles []ExternalSubtitle, +) tea.Cmd { + if output != config.VideoWindow && output != config.AudioTerminal { return pm.playTerminal(url, title, ratingKey, noReport, output, startOffset, subtitles) } return func() tea.Msg { - slog.Debug("PlayerManager.Play start", "title", title, "rk", ratingKey, "offset", startOffset) - if err := pm.ensureMpv(); err != nil { - slog.Error("PlayerManager: mpv check failed", "error", err) + previous, err := pm.start(url, title, ratingKey, noReport, output, startOffset, subtitles) + if err != nil { return err } - - // Strictly validate existing connection - if pm.conn != nil && !pm.VerifyConnection() { - slog.Debug("PlayerManager: cleaning up non-responsive connection") - pm.cleanup() + if previous.Active() { + previous.State = operations.StateStopped + reportProgress(context.Background(), previous) } + pm.sendUpdate() + reportProgress(context.Background(), pm.Status()) + return nil + } +} - if pm.conn != nil { - if pm.status.Key != "" && pm.status.Key != ratingKey { - slog.Debug("PlayerManager: different media, reporting progress first") - pm.reportProgressWithKey() - } - } +func (pm *PlayerManager) start( + url, title, ratingKey string, noReport bool, output config.VideoOutput, + startOffset int64, subtitles []ExternalSubtitle, +) (PlayerStatus, error) { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + previous := pm.Status() + select { + case <-pm.done: + return previous, fmt.Errorf("music player is closed") + default: + } + if err := pm.connect(); err != nil { + return previous, err + } + audioOnly := output == config.AudioTerminal + // Remove only our filter; preserve the user's other audio filters. + _ = pm.conn.call("af", "remove", "@plexctl-spectrum") + if audioOnly { + // Minimal builds may lack lavfi. Playback still works without meters. + _ = pm.conn.call("af", "add", spectrumFilter()) + } + if err := pm.conn.command(mediaLoadCommand(url, startOffset, audioOnly), nil); err != nil { + return previous, err + } + _ = pm.conn.call("set_property", "pause", false) + _ = pm.conn.call("set_property", "force-media-title", title) + _ = pm.conn.call("set_property", "user-data/plex-rating-key", ratingKey) + _ = pm.conn.call("set_property", "user-data/plex-title", title) + _ = pm.conn.call("set_property", "user-data/plex-no-report", noReport) + _ = pm.conn.call("set_property", "user-data/plex-audio-only", audioOnly) + for _, sub := range subtitles { + _ = pm.conn.call("sub-add", sub.URL, "auto", sub.Title, sub.Language) + } + pm.setStatus(PlayerStatus{ + Title: title, File: url, Key: ratingKey, NoReport: noReport, AudioOnly: audioOnly, + Time: float64(startOffset) / 1000, State: operations.StatePlaying, Volume: previous.Volume, + }) + return previous, nil +} - if pm.conn == nil { - slog.Debug("PlayerManager: spawning new mpv instance or re-opening connection") - if pm.socketExists() { - slog.Debug("PlayerManager: socket exists, attempting to connect") - c := mpvipc.NewConnection(pm.socketPath) - if err := c.Open(); err == nil { - pm.conn = c - if !pm.VerifyConnection() { - slog.Debug("PlayerManager: connected but IPC non-responsive, cleaning up") - pm.cleanup() - } else { - pm.stopChan = make(chan struct{}) - go pm.monitorEvents() - pm.restoreStatusFromMpv() - } - } else { - slog.Debug("PlayerManager: could not open socket, removing", "error", err) - os.Remove(pm.socketPath) - } +// connect is called with ioMu held. Connection identity prevents a stale +// monitor from clearing a replacement player. +func (pm *PlayerManager) connect() error { + if pm.conn != nil && pm.conn.get("mpv-version", nil) == nil { + return nil + } + pm.disconnect() + if conn, err := openIPC(pm.socketPath); err == nil { + if conn.get("mpv-version", nil) == nil { + pm.attach(conn) + return nil + } + _ = conn.conn.Close() + } + mpv, err := exec.LookPath("mpv") + if err != nil { + return fmt.Errorf("mpv command not found; please install mpv: %w", err) + } + if runtime.GOOS != "windows" { + _ = os.Remove(pm.socketPath) + } + // Embedded album art must never create a window for in-terminal music. + cmd := exec.Command(mpv, "--idle=yes", "--no-resume-playback", "--force-window=no", + "--terminal=no", "--input-terminal=no", "--input-ipc-server="+pm.socketPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("start mpv: %w", err) + } + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + retry := time.NewTicker(50 * time.Millisecond) + defer retry.Stop() + for { + select { + case <-exited: + return fmt.Errorf("mpv exited before opening its playback connection") + case <-deadline.C: + _ = cmd.Process.Kill() + <-exited + return fmt.Errorf("timed out connecting to mpv") + case <-retry.C: + conn, err := openIPC(pm.socketPath) + if err != nil { + continue } - - if pm.conn == nil { - if err := pm.spawnMpv(); err != nil { - slog.Error("PlayerManager: spawn failed", "error", err) - return err - } + if conn.get("mpv-version", nil) == nil { + pm.attach(conn) + return nil } + _ = conn.conn.Close() } + } +} - previous := pm.status - pm.status = PlayerStatus{ - Title: title, - File: url, - Key: ratingKey, - NoReport: noReport, - Time: float64(startOffset) / 1000.0, - } - pm.status.Play() - - if err := loadWindowMedia(pm.socketPath, url, startOffset); err != nil { - pm.status = previous - return err - } - pm.conn.Call("set_property", "pause", false) - pm.conn.Call("set_property", "force-media-title", title) - pm.conn.Call("set_property", "user-data/plex-rating-key", ratingKey) - pm.conn.Call("set_property", "user-data/plex-title", title) - pm.conn.Call("set_property", "user-data/plex-no-report", noReport) - pm.conn.Call("show-text", "PlexCTL: Loading "+title+"...", 5000) - - for _, sub := range subtitles { - if _, err := pm.conn.Call("sub-add", sub.URL, "auto", sub.Title, sub.Language); err != nil { - slog.Warn("Failed to add subtitle track", "title", sub.Title, "error", err) - } else { - slog.Debug("Added external subtitle", "title", sub.Title, "lang", sub.Language) +func (pm *PlayerManager) attach(conn *ipcClient) { + ctx, cancel := context.WithCancel(context.Background()) + pm.conn, pm.cancel = conn, cancel + reports := make(chan PlayerStatus, 4) + pm.workers.Add(2) + go func() { + defer pm.workers.Done() + pm.monitor(ctx, conn, reports) + }() + go func() { + defer pm.workers.Done() + for { + select { + case <-ctx.Done(): + return + case status := <-reports: + reportProgress(ctx, status) } } - - slog.Debug("PlayerManager: playback initiated") - pm.reportProgressWithKey() - pm.sendUpdate() - - return PlayerStatusMsg{} - } + }() } func (pm *PlayerManager) Reconnect() tea.Cmd { return func() tea.Msg { - if pm.conn != nil || !pm.socketExists() { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + select { + case <-pm.done: return nil + default: } - - c := mpvipc.NewConnection(pm.socketPath) - if err := c.Open(); err != nil { - os.Remove(pm.socketPath) + if pm.conn != nil { return nil } - - pm.conn = c - if !pm.VerifyConnection() { - slog.Debug("PlayerManager: re-connected but IPC non-responsive, cleaning up") - pm.cleanup() + conn, err := openIPC(pm.socketPath) + if err != nil { return nil } - - pm.stopChan = make(chan struct{}) - pm.restoreStatusFromMpv() - go pm.monitorEvents() - + if conn.get("mpv-version", nil) != nil { + _ = conn.conn.Close() + return nil + } + var status PlayerStatus + _ = conn.get("user-data/plex-title", &status.Title) + _ = conn.get("user-data/plex-rating-key", &status.Key) + _ = conn.get("user-data/plex-no-report", &status.NoReport) + _ = conn.get("user-data/plex-audio-only", &status.AudioOnly) + _ = conn.get("path", &status.File) + pm.setStatus(status) + pm.attach(conn) return nil } } -func (pm *PlayerManager) TogglePause() tea.Cmd { +func (pm *PlayerManager) control(args ...any) tea.Cmd { return func() tea.Msg { - if !pm.VerifyConnection() { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + if pm.conn == nil { return nil } - - if paused, err := pm.conn.Call("get_property", "pause"); err == nil && paused != nil { - newState := !paused.(bool) - pm.conn.Call("set_property", "pause", newState) - if newState { - pm.status.State = operations.StatePaused - } else { - pm.status.State = operations.StatePlaying - } - pm.sendUpdate() + if err := pm.conn.call(args...); err != nil { + return fmt.Errorf("player control failed: %w", err) } return nil } } -func (pm *PlayerManager) Stop() tea.Cmd { - return func() tea.Msg { - if pm.VerifyConnection() { - pm.conn.Call("quit") - pm.cleanup() - } - return tea.Quit() - } +func (pm *PlayerManager) TogglePause() tea.Cmd { return pm.control("cycle", "pause") } +func (pm *PlayerManager) Seek(seconds float64) tea.Cmd { + return pm.control("seek", seconds, "relative") +} +func (pm *PlayerManager) SeekPercent(percent float64) tea.Cmd { + return pm.control("seek", max(0, min(100, percent)), "absolute-percent") } +func (pm *PlayerManager) Volume(delta float64) tea.Cmd { return pm.control("add", "volume", delta) } +func (pm *PlayerManager) ToggleMute() tea.Cmd { return pm.control("cycle", "mute") } func (pm *PlayerManager) StopPlayback() tea.Cmd { return func() tea.Msg { - if pm.VerifyConnection() { - pm.conn.Call("quit") - pm.cleanup() - } else { - // Even if connection is lost, try to clean up local state - pm.cleanup() - } - return PlayerStatusMsg{} // Trigger UI update to hide controls - } -} - -func (pm *PlayerManager) PollUpdates() tea.Cmd { - return tea.Tick(1*time.Second, func(t time.Time) tea.Msg { - if !pm.status.Active() { - return nil + pm.ioMu.Lock() + previous := pm.Status() + if pm.conn != nil { + _ = pm.conn.call("quit") } - return PlayerStatusMsgPoller{} - }) -} - -func (pm *PlayerManager) sendUpdate() { - // Updates are notifications to read the latest status. Coalesce them while - // Bubble Tea is suspended for terminal playback so the monitor cannot block. - select { - case pm.updates <- PlayerStatusMsg{}: - default: - } -} - -func (pm *PlayerManager) WaitForUpdates() tea.Cmd { - return func() tea.Msg { - msg := <-pm.updates - return msg + pm.disconnect() + pm.setStatus(PlayerStatus{}) + pm.ioMu.Unlock() + previous.State = operations.StateStopped + reportProgress(context.Background(), previous) + pm.sendUpdate() + return nil } } -// Internal Helpers - -func (pm *PlayerManager) ensureMpv() error { - _, err := exec.LookPath("mpv") - if err != nil { - return fmt.Errorf("mpv command not found. please install mpv: %w", err) - } - return nil +func (pm *PlayerManager) Stop() tea.Cmd { + return func() tea.Msg { pm.StopPlayback()(); return tea.Quit() } } -func (pm *PlayerManager) socketExists() bool { - if runtime.GOOS == "windows" { - _, err := os.OpenFile(pm.socketPath, os.O_RDWR, 0) - if err != nil { - if pe, ok := err.(*os.PathError); ok { - if errno, ok := pe.Err.(syscall.Errno); ok { - if errno == 2 { - return false - } - } - } - return true +// Close stops in-terminal music and releases IPC. An external player the user +// elected to keep running can continue after the TUI exits. +func (pm *PlayerManager) Close() { + pm.closeOnce.Do(func() { + close(pm.done) + pm.ioMu.Lock() + status := pm.Status() + if status.AudioOnly && pm.conn != nil { + _ = pm.conn.call("quit") } - return true // Named pipes are harder to stat, let dialing handle it - } - _, err := os.Stat(pm.socketPath) - return err == nil + pm.disconnect() + pm.ioMu.Unlock() + pm.workers.Wait() + }) } -func (pm *PlayerManager) spawnMpv() error { - args := []string{"--idle", "--no-resume-playback", fmt.Sprintf("--input-ipc-server=%s", pm.socketPath), "--force-window=yes"} - - slog.Debug("PlayerManager: spawning mpv", "args", args) - cmd := exec.Command("mpv", args...) - - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start mpv: %w", err) +func (pm *PlayerManager) disconnect() { + if pm.cancel != nil { + pm.cancel() + pm.cancel = nil } - - // Wait for socket to appear and respond to IPC - for i := 0; i < 50; i++ { - if pm.socketExists() { - c := mpvipc.NewConnection(pm.socketPath) - if err := c.Open(); err == nil { - pm.conn = c - // Crucial: wait for IPC to actually respond - if pm.VerifyConnection() { - pm.stopChan = make(chan struct{}) - go pm.monitorEvents() - return nil - } - pm.conn.Close() - pm.conn = nil - } - } - time.Sleep(100 * time.Millisecond) - } - return fmt.Errorf("failed to connect to mpv IPC after spawning") -} - -func (pm *PlayerManager) setupObservers() { if pm.conn != nil { - pm.conn.Call("observe_property", 1, "time-pos") - pm.conn.Call("observe_property", 2, "pause") - pm.conn.Call("observe_property", 3, "duration") + _ = pm.conn.conn.Close() + pm.conn = nil } } -func (pm *PlayerManager) monitorEvents() { - pm.setupObservers() - events := make(chan *mpvipc.Event) - stop := make(chan struct{}) - - go func() { - if pm.conn != nil { - pm.conn.ListenForEvents(events, stop) - } - }() - - ticker := time.NewTicker(10 * time.Second) +func (pm *PlayerManager) monitor(ctx context.Context, conn *ipcClient, reports chan PlayerStatus) { + ticker := time.NewTicker(50 * time.Millisecond) defer ticker.Stop() - - defer func() { - rk := pm.status.Key - nr := pm.status.NoReport - pm.cleanup() - pm.sendUpdate() - if rk != "" && !nr { - pm.reportProgressWithKey() - } - }() - + lastReport := time.Now() for { select { - case <-pm.stopChan: - close(stop) + case <-ctx.Done(): return - case ev, ok := <-events: - if !ok || ev.Name == "shutdown" { - slog.Debug("PlayerManager: mpv shutdown detected") + case <-ticker.C: + pm.ioMu.Lock() + if ctx.Err() != nil || pm.conn != conn { + pm.ioMu.Unlock() return } - if ev.Name == "end-file" { - slog.Debug("PlayerManager: playback finished (end-file)") - pm.status.State = operations.StateStopped - pm.reportProgressWithKey() + previous := pm.Status() + status := previous + var idle, paused bool + err := conn.get("idle-active", &idle) + if err == nil && !idle { + _ = conn.get("pause", &paused) + status.State = operations.StatePlaying + if paused { + status.State = operations.StatePaused + } + _ = conn.get("time-pos", &status.Time) + _ = conn.get("duration", &status.Duration) + _ = conn.get("volume", &status.Volume) + _ = conn.get("mute", &status.Muted) + if status.AudioOnly && !paused { + var metadata map[string]string + _ = conn.get("af-metadata/plexctl-spectrum", &metadata) + status.Spectrum = readSpectrum(metadata) + } + } else { + status.State = operations.StateStopped + status.Spectrum = Spectrum{} } - if ev.Name == "property-change" { - slog.Log(context.Background(), config.LevelTrace, "PlayerManager: property change", "id", ev.ID, "data", ev.Data) - pm.handlePropertyChange(ev) + pm.setStatus(status) + if err != nil { + pm.disconnect() } - case <-ticker.C: - if pm.status.Active() && pm.status.Key != "" { - slog.Log(context.Background(), config.LevelTrace, "PlayerManager: periodic progress report") - pm.reportProgressWithKey() + pm.ioMu.Unlock() + if previous.State != status.State { + pm.sendUpdate() } - } - } -} - -func (pm *PlayerManager) handlePropertyChange(ev *mpvipc.Event) { - if ev.Data == nil { - return - } - switch ev.ID { - case 1: // time-pos - if val, ok := ev.Data.(float64); ok { - pm.status.Time = val - } - case 2: // pause - if val, ok := ev.Data.(bool); ok { - if val { - pm.status.State = operations.StatePaused - } else { - pm.status.State = operations.StatePlaying + if previous.State != status.State || time.Since(lastReport) >= 10*time.Second { + // Plex HTTP latency must never stall visualization or controls. + select { + case reports <- status: + default: + } + lastReport = time.Now() + } + if err != nil { + // The connection context has been canceled; report the final + // position with its own bounded request after an external exit. + if previous.Active() { + reportProgress(context.Background(), status) + } + return } } - pm.sendUpdate() - case 3: // duration - if val, ok := ev.Data.(float64); ok { - pm.status.Duration = val - } - pm.sendUpdate() } } -func (pm *PlayerManager) restoreStatusFromMpv() { - if pm.conn == nil { - return - } - title, _ := pm.conn.Call("get_property", "user-data/plex-title") - if title == nil { - title, _ = pm.conn.Call("get_property", "media-title") +func (pm *PlayerManager) PollUpdates() tea.Cmd { + interval := 250 * time.Millisecond + if status := pm.Status(); status.Active() && status.AudioOnly { + interval = 50 * time.Millisecond } - key, _ := pm.conn.Call("get_property", "user-data/plex-rating-key") - path, _ := pm.conn.Call("get_property", "path") - paused, _ := pm.conn.Call("get_property", "pause") - idle, _ := pm.conn.Call("get_property", "idle-active") - timePos, _ := pm.conn.Call("get_property", "time-pos") - duration, _ := pm.conn.Call("get_property", "duration") - noReport, _ := pm.conn.Call("get_property", "user-data/plex-no-report") + return tea.Tick(interval, func(time.Time) tea.Msg { return PlayerStatusMsgPoller{} }) +} - if title != nil { - pm.status.Title, _ = title.(string) - } - if key != nil { - pm.status.Key, _ = key.(string) - } - if noReport != nil { - if nrB, ok := noReport.(bool); ok { - pm.status.NoReport = nrB - } - } - if path != nil { - pm.status.File, _ = path.(string) - } - if idle != nil { - if idleB, ok := idle.(bool); ok && idleB { - pm.status.State = operations.StateStopped - } else { - if paused != nil { - if pausedB, ok := paused.(bool); ok && pausedB { - pm.status.State = operations.StatePaused - } else { - pm.status.State = operations.StatePlaying - } - } - } - } - if timePos != nil { - pm.status.Time, _ = timePos.(float64) - } - if duration != nil { - pm.status.Duration, _ = duration.(float64) +func (pm *PlayerManager) sendUpdate() { + select { + case pm.updates <- PlayerStatusMsg{}: + default: } } -func (pm *PlayerManager) cleanup() { - if pm.stopChan != nil { +func (pm *PlayerManager) WaitForUpdates() tea.Cmd { + return func() tea.Msg { select { - case <-pm.stopChan: - default: - close(pm.stopChan) + case msg := <-pm.updates: + return msg + case <-pm.done: + return nil } - pm.stopChan = nil } - if pm.conn != nil { - pm.conn.Close() - pm.conn = nil - } - pm.status = PlayerStatus{} - if runtime.GOOS != "windows" { - os.Remove(pm.socketPath) - } -} - -func (pm *PlayerManager) reportProgressWithKey() { - pm.sendUpdate() - reportProgress(context.Background(), pm.status) } func reportProgress(ctx context.Context, status PlayerStatus) { if status.NoReport || status.Key == "" { return } - - timePos := int64(status.Time * 1000) - duration := int64(status.Duration * 1000) - + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() client, err := plex.NewClient() if err != nil { return } - - st := status.State - forNow := "PlexCTL" - req := operations.ReportRequest{ - State: &st, - Time: &timePos, - Duration: &duration, - Key: &status.Key, - RatingKey: &status.Key, - Device: &forNow, - } - _, _ = client.SDK.Timeline.Report(ctx, req) + timePos, duration := int64(status.Time*1000), int64(status.Duration*1000) + device := "PlexCTL" + _, _ = client.SDK.Timeline.Report(ctx, operations.ReportRequest{ + State: &status.State, Time: &timePos, Duration: &duration, + Key: &status.Key, RatingKey: &status.Key, Device: &device, + }) } diff --git a/internal/tui/player/player_state.go b/internal/tui/player/player_state.go index e30efb0..6229d80 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -3,13 +3,17 @@ package player import "github.com/LukeHagar/plexgo/models/operations" type PlayerStatus struct { - Time float64 - Duration float64 - Title string - File string - Key string - State operations.State - NoReport bool + Time float64 + Duration float64 + Title string + File string + Key string + State operations.State + NoReport bool + AudioOnly bool + Volume float64 + Muted bool + Spectrum Spectrum } type ExternalSubtitle struct { diff --git a/internal/tui/player/spectrum.go b/internal/tui/player/spectrum.go new file mode 100644 index 0000000..897cf1f --- /dev/null +++ b/internal/tui/player/spectrum.go @@ -0,0 +1,64 @@ +package player + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +const SpectrumBands = 16 + +// Spectrum is measured from the current decoded audio frame, in dBFS. It is +// independent of the sound device, graphics protocol, tmux, and SSH transport. +type Spectrum struct { + Bands [SpectrumBands]float64 + Left float64 + Right float64 + Valid bool +} + +var bandFrequencies = [SpectrumBands]int{ + 40, 63, 100, 160, 250, 400, 630, 1000, + 1600, 2500, 4000, 6300, 8000, 10000, 12500, 16000, +} + +// spectrumFilter measures parallel bandpass copies, then discards those copies +// with pan. The first two channels carry the original stereo audio untouched; +// astats frame metadata survives pan and is exposed through mpv's af-metadata. +// No second decoder, microphone capture, growing file, or external tool is used. +func spectrumFilter() string { + var graph strings.Builder + fmt.Fprintf(&graph, "[in]aformat=sample_fmts=fltp:channel_layouts=stereo,asplit=%d[main]", SpectrumBands+1) + for i := range SpectrumBands { + fmt.Fprintf(&graph, "[s%d]", i) + } + for i, frequency := range bandFrequencies { + fmt.Fprintf(&graph, ";[s%d]pan=mono|c0=0.5*c0+0.5*c1,bandpass=f=%d:t=o:w=0.75[b%d]", i, frequency, i) + } + graph.WriteString(";[main]") + for i := range SpectrumBands { + fmt.Fprintf(&graph, "[b%d]", i) + } + fmt.Fprintf(&graph, "amerge=inputs=%d,astats=metadata=1:reset=1:measure_perchannel=RMS_level:measure_overall=none,"+ + "pan=stereo|c0=c0|c1=c1[out]", SpectrumBands+1) + return "@plexctl-spectrum:lavfi=[" + graph.String() + "]" +} + +func readSpectrum(metadata map[string]string) Spectrum { + var frame Spectrum + level := func(channel int) float64 { + text, ok := metadata[fmt.Sprintf("lavfi.astats.%d.RMS_level", channel)] + value, err := strconv.ParseFloat(text, 64) + if !ok || err != nil || math.IsNaN(value) || math.IsInf(value, 1) { + return -100 + } + frame.Valid = true + return max(-100, min(0, value)) + } + frame.Left, frame.Right = level(1), level(2) + for i := range SpectrumBands { + frame.Bands[i] = level(i + 3) + } + return frame +} diff --git a/internal/tui/player/spectrum_test.go b/internal/tui/player/spectrum_test.go new file mode 100644 index 0000000..fd9b2d6 --- /dev/null +++ b/internal/tui/player/spectrum_test.go @@ -0,0 +1,27 @@ +package player + +import ( + "math" + "testing" +) + +func TestSpectrumHandlesSilenceAndMissingMetadata(t *testing.T) { + for _, tt := range []struct { + name string + value string + valid bool + level float64 + }{ + {name: "signal", value: "-20.25", valid: true, level: -20.25}, + {name: "silence", value: "-inf", valid: true, level: -100}, + {name: "invalid", value: "nan", level: -100}, + {name: "missing", level: -100}, + } { + t.Run(tt.name, func(t *testing.T) { + frame := readSpectrum(map[string]string{"lavfi.astats.3.RMS_level": tt.value}) + if frame.Valid != tt.valid || frame.Bands[0] != tt.level || math.IsNaN(frame.Bands[0]) { + t.Fatalf("unexpected frame: %+v", frame) + } + }) + } +} diff --git a/internal/tui/player/window_load.go b/internal/tui/player/window_load.go index 2cb78e4..0e5bbfb 100644 --- a/internal/tui/player/window_load.go +++ b/internal/tui/player/window_load.go @@ -20,10 +20,7 @@ func loadWindowMedia(socket, url string, startOffset int64) error { } request := map[string]any{ "request_id": 1, - "command": map[string]any{ - "name": "loadfile", "url": url, "flags": "replace", - "options": map[string]string{"start": fmt.Sprintf("%.3f", float64(startOffset)/1000)}, - }, + "command": mediaLoadCommand(url, startOffset, false), } if err := json.NewEncoder(conn).Encode(request); err != nil { return fmt.Errorf("send playback command: %w", err) @@ -46,3 +43,15 @@ func loadWindowMedia(socket, url string, startOffset int64) error { return nil } } + +func mediaLoadCommand(url string, startOffset int64, audioOnly bool) map[string]any { + options := map[string]string{ + "start": fmt.Sprintf("%.3f", float64(startOffset)/1000), + "vid": "auto", "force-window": "yes", + } + if audioOnly { + options["vid"] = "no" + options["force-window"] = "no" + } + return map[string]any{"name": "loadfile", "url": url, "flags": "replace", "options": options} +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 26d4770..0f7f237 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -20,11 +20,14 @@ import ( "github.com/ygelfand/plexctl/internal/tui/view/detail" tuiconfig "github.com/ygelfand/plexctl/internal/tui/widget/config" "github.com/ygelfand/plexctl/internal/tui/widget/help" + "github.com/ygelfand/plexctl/internal/tui/widget/letterjump" "github.com/ygelfand/plexctl/internal/tui/widget/playback" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" "github.com/ygelfand/plexctl/internal/tui/widget/resume" tuisearch "github.com/ygelfand/plexctl/internal/tui/widget/search" "github.com/ygelfand/plexctl/internal/tui/widget/settings" "github.com/ygelfand/plexctl/internal/tui/widget/userpicker" + "github.com/ygelfand/plexctl/internal/tui/widget/visualizer" "github.com/ygelfand/plexctl/internal/ui" "go.dalton.dog/bubbleup" ) @@ -40,6 +43,8 @@ type Controller struct { playerStatus player.PlayerStatus returnTabIdx int + visualizer visualizer.Model + visualTick time.Time } type switchUserSuccessMsg struct{} @@ -102,7 +107,6 @@ func (c *Controller) Init() tea.Cmd { cmds = append(cmds, overlay.Init()) } - cmds = append(cmds, c.player.WaitForUpdates()) cmds = append(cmds, c.player.PollUpdates()) cmds = append(cmds, c.player.Reconnect()) cmds = append(cmds, c.alert.Init()) @@ -118,11 +122,40 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case view.SearchIndexProgressMsg, view.SearchIndexFinishedMsg: + for _, model := range c.tabManager.tabModels { + if status, ok := model.(*view.SearchStatusView); ok { + _, cmd := status.Update(msg) + cmds = append(cmds, cmd) + } + } + return c, tea.Batch(cmds...) + case poster.PosterLoadedMsg, detail.PosterDataMsg: + for _, model := range c.tabManager.tabModels { + if model != nil { + _, cmd := model.Update(msg) + cmds = append(cmds, cmd) + } + } + return c, tea.Batch(cmds...) + case ui.MediaPageMsg: + // A request belongs to its library even if the user changed tabs or + // opened a dialog while the server was responding. + for _, model := range c.tabManager.tabModels { + if library, ok := model.(*view.MediaView); ok && library.GetSectionID() == msg.SectionID { + _, cmd := library.Update(msg) + return c, cmd + } + } + return c, nil case tea.WindowSizeMsg: + ui.GetLayout().SetPlayerHeight(c.playerHeight(msg.Height)) ui.GetLayout().Update(msg.Width, msg.Height, c.playerStatus.Active()) for i, model := range c.tabManager.tabModels { if model != nil { - c.tabManager.tabModels[i], _ = model.Update(msg) + var cmd tea.Cmd + c.tabManager.tabModels[i], cmd = model.Update(msg) + cmds = append(cmds, cmd) } } @@ -135,7 +168,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } } - return c, c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth) + return c, tea.Batch(c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth), resizePlayer()) case tuiconfig.ConfigFinishedMsg: cfg := config.Get() @@ -182,14 +215,21 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth) } - if navCmd, captured := c.navigator.Update(msg); captured { + navCmd, captured := c.navigator.Update(msg) + if captured { return c, navCmd } + cmds = append(cmds, navCmd) if mouse, ok := msg.(tea.MouseMsg); ok { return c.handleMouse(mouse) } switch msg := msg.(type) { + case ui.JumpToLetterMsg: + if target, ok := c.tabManager.ActiveModel().(ui.LetterJumper); ok && target.CanJumpLetters() { + return c, target.JumpToLetter(msg.Letter) + } + return c, nil case ui.RequestPlayMsg: return c, player.FetchAndPlay(msg.RatingKey, msg.Output) case ui.ResumeChoiceMsg: @@ -199,40 +239,20 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ui.JumpToDetailMsg: return c, c.handleJumpToDetail(msg) case player.PlayerStatusMsgPoller: - if msg == (player.PlayerStatusMsgPoller{}) && !c.playerStatus.Active() { - return c, nil - } - oldActive := c.playerStatus.Active() - c.playerStatus = c.player.Status() - if oldActive != c.playerStatus.Active() { - ui.GetLayout().Update(ui.GetLayout().TotalWidth(), ui.GetLayout().TotalHeight(), c.playerStatus.Active()) - cmds = append(cmds, func() tea.Msg { - return tea.WindowSizeMsg{Width: ui.GetLayout().TotalWidth(), Height: ui.GetLayout().TotalHeight()} - }) - } - if c.playerStatus.Active() { - cmds = append(cmds, c.player.PollUpdates()) - } + cmds = append(cmds, c.refreshPlayer(), c.player.PollUpdates()) return c, tea.Batch(cmds...) case player.PlayerStatusMsg: - oldActive := c.playerStatus.Active() - c.playerStatus = c.player.Status() - if oldActive != c.playerStatus.Active() { - ui.GetLayout().Update(ui.GetLayout().TotalWidth(), ui.GetLayout().TotalHeight(), c.playerStatus.Active()) - cmds = append(cmds, func() tea.Msg { - return tea.WindowSizeMsg{Width: ui.GetLayout().TotalWidth(), Height: ui.GetLayout().TotalHeight()} - }) - if c.playerStatus.Active() { - cmds = append(cmds, c.player.PollUpdates()) - } - } - return c, tea.Batch(append(cmds, c.player.WaitForUpdates())...) + return c, c.refreshPlayer() case tea.KeyMsg: slog.Log(context.Background(), config.LevelTrace, "TUI: key press", "key", msg.String()) switch msg.String() { + case "g": + if target, ok := c.tabManager.ActiveModel().(ui.LetterJumper); ok && target.CanJumpLetters() { + return c, c.navigator.Push(letterjump.New(c.theme)) + } case "q", "ctrl+c": - if config.Get().CloseVideoOnQuit { + if config.Get().CloseVideoOnQuit || c.playerStatus.AudioOnly { return c, c.player.Stop() } return c, tea.Quit @@ -250,6 +270,15 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if provider, ok := c.tabManager.ActiveModel().(ui.PlayableProvider); ok { meta := provider.GetSelectedMetadata() if meta != nil && meta.RatingKey != nil { + if config.IsMusic(meta.Type) { + if msg.String() == "v" { + return c, c.navigator.Push(playback.NewMusic(*meta.RatingKey, meta.Title, c.theme)) + } + output = config.Get().MusicOutput() + if msg.String() == "ctrl+p" { + output = config.AudioTerminal + } + } if msg.String() == "v" { return c, c.navigator.Push(playback.New(*meta.RatingKey, meta.Title, c.theme)) } @@ -273,6 +302,10 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if c.playerStatus.Active() { return c, c.player.StopPlayback() } + case "[", "]", "+", "=", "-", "m", "z": + if c.playerStatus.Active() { + return c, c.musicControl(msg.String()) + } case "/": return c, c.navigator.Push(tuisearch.NewSearchOverlayModel(c.theme)) case "u": @@ -483,13 +516,18 @@ func (c *Controller) showHelp() tea.Cmd { {Key: "wheel", Desc: "Scroll under pointer; Shift+wheel browses a poster row"}, {Key: "right click", Desc: "Back / close dialog (Shift bypasses mouse capture)"}, {Key: "tab", Desc: "Switch Library"}, + {Key: "g, then A–Z / #", Desc: "Jump to a letter (or click the right-hand alphabet)"}, + {Key: "Home / End / PgUp / PgDn", Desc: "First / last item / previous / next page"}, {Key: "h", Desc: "Home"}, {Key: "ctrl+s", Desc: "Global Settings"}, {Key: "ctrl+l", Desc: "Libraries"}, {Key: "u", Desc: "Switch User"}, {Key: "p", Desc: "Play Selected"}, {Key: "ctrl+p", Desc: "Play in Terminal"}, - {Key: "v", Desc: "Terminal Playback (Kitty / Sixel / Text)"}, + {Key: "v", Desc: "Choose player (music: terminal / window; video: renderer)"}, + {Key: "[ / ]", Desc: "Seek backward / forward 10 seconds"}, + {Key: "+ / - / m", Desc: "Volume up / down / mute"}, + {Key: "z", Desc: "Cycle music visualizer: spectrum / mirror / off"}, {Key: "x", Desc: "Stop Playback"}, {Key: "q", Desc: "Quit"}, {Key: "?", Desc: "Help"}, @@ -561,9 +599,9 @@ func (c *Controller) renderBaseView() string { } func (c *Controller) footerText() string { - text := "[p] Play | [v] Video | [V] View | [/] Search | [?] Help | [q] Quit" + text := "[p] Play | [v] Player | [V] View | [/] Search | [?] Help | [q] Quit" if c.playerStatus.Active() { - text = "[p] Play | [v] Video | [x] Stop | [/] Search | [?] Help | [q] Quit" + text = "[p] Play | [v] Player | [x] Stop | [/] Search | [?] Help | [q] Quit" } return ui.Ellipsis(text, max(1, ui.GetLayout().TotalWidth()-2)) } @@ -598,9 +636,9 @@ func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { rect := ui.Rect{X: ui.SidebarTotalWidth + 2, Y: 1, Width: layout.InnerWidth(), Height: layout.ContentHeight()} local, inside := rect.Local(mouse) if !inside { - if c.playerStatus.Active() && ui.LeftClick(mouse) && mouse.X > ui.SidebarTotalWidth && + if c.playerStatus.Active() && mouse.X > ui.SidebarTotalWidth && mouse.Y >= rect.Height+2 && mouse.Y < layout.TotalHeight()-1 { - return c, c.player.TogglePause() + return c, c.playerMouse(mouse, rect.Height+2) } return c, nil } @@ -623,6 +661,9 @@ func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { func (c *Controller) renderPlayer() string { layout := ui.GetLayout() + if c.playerStatus.AudioOnly { + return c.renderMusicPlayer() + } width := layout.MainAreaContentWidth() status := "▶ PLAYING" if c.playerStatus.Paused() { @@ -631,7 +672,7 @@ func (c *Controller) renderPlayer() string { progress := 0.0 if c.playerStatus.Duration > 0 { - progress = c.playerStatus.Time / c.playerStatus.Duration + progress = max(0, min(1, c.playerStatus.Time/c.playerStatus.Duration)) } barWidth := max(width-35, 10) diff --git a/internal/tui/view/detail/base.go b/internal/tui/view/detail/base.go index 73770bb..80918e4 100644 --- a/internal/tui/view/detail/base.go +++ b/internal/tui/view/detail/base.go @@ -10,14 +10,15 @@ import ( ) type DetailBase struct { - RatingKey string - Metadata *components.Metadata - Poster string - Width int - Height int - Theme tint.Tint - Err error - Loading bool + RatingKey string + Metadata *components.Metadata + Poster string + posterWidth int + Width int + Height int + Theme tint.Tint + Err error + Loading bool SynopsisVP viewport.Model } @@ -58,8 +59,12 @@ func (b *DetailBase) Update(msg tea.Msg) tea.Cmd { b.Metadata = msg.metadata b.UpdateLayout() cmds = append(cmds, fetchPoster(b.Metadata, b.Width)) - case posterDataMsg: - b.Poster = string(msg) + case PosterDataMsg: + if msg.ratingKey != b.RatingKey || msg.width != detailPosterWidth(b.Metadata) { + return nil + } + b.Poster = msg.view + b.posterWidth = msg.width b.UpdateLayout() case error: b.Loading = false diff --git a/internal/tui/view/detail/detail_shared.go b/internal/tui/view/detail/detail_shared.go index fd99ebd..cd01101 100644 --- a/internal/tui/view/detail/detail_shared.go +++ b/internal/tui/view/detail/detail_shared.go @@ -29,7 +29,11 @@ type detailDataMsg struct { children []components.Metadata } -type posterDataMsg string +type PosterDataMsg struct { + ratingKey string + width int + view string +} type BackToLibraryMsg struct{} @@ -51,9 +55,26 @@ func (i metadataItem) FilterValue() string { return i.metadata.Title } // Shared Helpers +func detailPosterWidth(metadata *components.Metadata) int { + if metadata != nil && (metadata.Type == "album" || metadata.Type == "track") { + layout := ui.GetLayout() + // Leave room for track titles beside the cover and shrink with the player. + return max(6, min(ui.DetailPosterWidth, (layout.InnerWidth()-8)/3, (layout.ContentHeight()-10)*2)) + } + return ui.DetailPosterWidth +} + func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { + targetWidth := detailPosterWidth(metadata) return func() tea.Msg { - if metadata == nil || metadata.Thumb == nil { + if metadata == nil { + return nil + } + path := ui.PtrToString(metadata.Thumb) + if metadata.Type == "track" && metadata.ParentThumb != nil { + path = *metadata.ParentThumb + } + if path == "" { return nil } @@ -62,19 +83,19 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { rk = *metadata.RatingKey } - targetWidth := ui.DetailPosterWidth - slog.Debug("fetchPoster start", "title", metadata.Title, "ratingKey", rk, "width", targetWidth) // Check long-term cache for rendered string if rk != "" { if cached, ok := artwork.Cached(rk, targetWidth); ok { - return posterDataMsg(cached) + return PosterDataMsg{ratingKey: rk, width: targetWidth, view: cached} } } start := time.Now() - data, err := plex.GetImage(context.Background(), *metadata.Thumb) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + data, err := plex.GetImage(ctx, path) if err != nil { slog.Error("fetchPoster image fetch failed", "error", err) return nil @@ -95,7 +116,7 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { } slog.Debug("fetchPoster render complete", "duration", time.Since(start)) - return posterDataMsg(imgStr) + return PosterDataMsg{ratingKey: rk, width: targetWidth, view: imgStr} } } diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go index 8c1a6b6..0ebe7ce 100644 --- a/internal/tui/view/detail/music_detail.go +++ b/internal/tui/view/detail/music_detail.go @@ -3,15 +3,18 @@ package detail import ( "context" "fmt" + "slices" "strings" "time" "github.com/LukeHagar/plexgo/models/components" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/paginator" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" "github.com/ygelfand/plexctl/internal/ui" ) @@ -19,13 +22,20 @@ import ( // parent's selection and scroll position when returning from a child. type MusicDetailView struct { DetailBase - items list.Model - listRect ui.Rect - selected *MusicDetailView + items list.Model + listRect ui.Rect + selected *MusicDetailView + albums *poster.PosterGrid + albumsRect ui.Rect + rail ui.AlphabetRail + jumpNote string } func NewMusicDetailView(ratingKey string, theme tint.Tint) *MusicDetailView { - return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme), items: createBaseDetailList()} + items := createBaseDetailList() + items.Paginator.Type = paginator.Arabic + return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme), items: items, + albums: poster.NewPosterGrid(nil, theme)} } func (v *MusicDetailView) Init() tea.Cmd { return v.fetchData } @@ -59,6 +69,9 @@ func (v *MusicDetailView) GetSelectedMetadata() *components.Metadata { if v.selected != nil { return v.selected.GetSelectedMetadata() } + if v.Metadata != nil && v.Metadata.Type == "artist" && len(v.albums.Items) > 0 { + return &v.albums.Items[v.albums.Cursor].Metadata + } if item, ok := v.items.SelectedItem().(musicItem); ok { return &item.metadata } @@ -67,16 +80,65 @@ func (v *MusicDetailView) GetSelectedMetadata() *components.Metadata { func (v *MusicDetailView) IsAtRoot() bool { return v.selected == nil } +func (v *MusicDetailView) CanJumpLetters() bool { + if v.selected != nil { + return v.selected.CanJumpLetters() + } + return v.Metadata != nil && (v.Metadata.Type == "artist" || v.Metadata.Type == "album") +} + +func (v *MusicDetailView) JumpToLetter(letter string) tea.Cmd { + if v.selected != nil { + return v.selected.JumpToLetter(letter) + } + if !v.CanJumpLetters() { + return nil + } + v.jumpNote = "" + if v.Metadata.Type == "artist" { + for i, item := range v.albums.Items { + if ui.AlphabetLetter(item.Metadata) == letter { + return v.albums.SetCursor(i) + } + } + } else { + for i, item := range v.items.Items() { + if ui.AlphabetLetter(item.(musicItem).metadata) == letter { + v.items.Select(i) + return nil + } + } + } + v.jumpNote = "No titles under " + letter + return nil +} + func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if image, ok := msg.(PosterDataMsg); ok && image.ratingKey == v.RatingKey { + return v, v.DetailBase.Update(image) + } + if posterMsg, ok := msg.(poster.PosterLoadedMsg); ok { + v.albums.Update(posterMsg) + } if v.selected != nil { if _, back := msg.(BackMsg); back && v.selected.IsAtRoot() { v.selected = nil - return v, nil + return v, tea.Batch(v.albums.Init(), fetchPoster(v.Metadata, v.Width)) } _, cmd := v.selected.Update(msg) return v, cmd } if mouse, ok := msg.(tea.MouseMsg); ok { + if letter := v.rail.LetterAt(mouse); letter != "" { + return v, v.JumpToLetter(letter) + } + if v.Metadata != nil && v.Metadata.Type == "artist" { + if local, inside := v.albumsRect.Local(mouse); inside { + _, cmd := v.albums.Update(local) + return v, cmd + } + return v, nil + } if local, inside := v.listRect.Local(mouse); inside { if ui.ListMouse(&v.items, local) { return v.Update(ui.KeyPress("enter")) @@ -86,6 +148,14 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { case detailDataMsg: + if msg.metadata != nil && msg.metadata.RatingKey != nil && *msg.metadata.RatingKey != v.RatingKey { + return v, nil + } + if msg.metadata != nil && msg.metadata.Type == "artist" { + slices.SortStableFunc(msg.children, func(a, b components.Metadata) int { + return strings.Compare(ui.AlphabetTitle(a), ui.AlphabetTitle(b)) + }) + } items := make([]list.Item, 0, len(msg.children)) for _, child := range msg.children { items = append(items, musicItem{metadataItem{metadata: child}}) @@ -94,19 +164,51 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.items.Title = "Tracks" if msg.metadata != nil && msg.metadata.Type == "artist" { v.items.Title = "Albums" + baseCmd := v.DetailBase.Update(msg) + posterCmd := v.albums.SetItems(msg.children) + v.View() // Establish the embedded grid's bounds before navigation. + return v, tea.Batch(baseCmd, posterCmd, v.albums.Init()) } + case poster.PosterLoadedMsg: + _, cmd := v.albums.Update(msg) + return v, cmd + case poster.ItemSelectedMsg: + if msg.Metadata.RatingKey != nil { + v.selected = NewMusicDetailView(*msg.Metadata.RatingKey, v.Theme) + return v, v.selected.Init() + } + case tea.WindowSizeMsg: + baseCmd := v.DetailBase.Update(msg) + v.View() + return v, tea.Batch(baseCmd, v.albums.Init()) + case ui.ThemeChangedMsg: + v.albums.Theme = msg.Theme case tea.KeyMsg: switch msg.String() { case "enter": - if item, ok := v.items.SelectedItem().(musicItem); ok && item.metadata.RatingKey != nil { - v.selected = NewMusicDetailView(*item.metadata.RatingKey, v.Theme) + if meta := v.GetSelectedMetadata(); meta != nil && meta.RatingKey != nil && meta.Type != "artist" { + v.selected = NewMusicDetailView(*meta.RatingKey, v.Theme) return v, v.selected.Init() } case "esc", "backspace": return v, func() tea.Msg { return BackMsg{} } + case "home": + if v.Metadata != nil && v.Metadata.Type == "album" { + v.items.Select(0) + return v, nil + } + case "end": + if v.Metadata != nil && v.Metadata.Type == "album" { + v.items.Select(max(0, len(v.items.Items())-1)) + return v, nil + } } } baseCmd := v.DetailBase.Update(msg) + if v.Metadata != nil && v.Metadata.Type == "artist" { + _, cmd := v.albums.Update(msg) + return v, tea.Batch(baseCmd, cmd) + } var listCmd tea.Cmd v.items, listCmd = v.items.Update(msg) return v, tea.Batch(baseCmd, listCmd) @@ -116,7 +218,9 @@ func (v *MusicDetailView) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "enter", Desc: "Open Album / Track Details"}, {Key: "p", Desc: "Play Selected Track (First Track of an Album)"}, + {Key: "v", Desc: "Choose Terminal Music Player / Separate Window"}, {Key: "↑/↓", Desc: "Select Album / Track"}, + {Key: "g then letter", Desc: "Jump to an album or track title"}, {Key: "esc", Desc: "Back"}, } } @@ -140,21 +244,73 @@ func (v *MusicDetailView) View() string { infoWidth = v.SynopsisVP.Width } info := musicItem{metadataItem{metadata: *v.Metadata}}.Description() + if v.Metadata.Type == "artist" { + info = fmt.Sprintf("%d albums · Select a cover to browse tracks", len(v.albums.Items)) + } header := lipgloss.JoinVertical(lipgloss.Left, v.RenderHeader(infoWidth), lipgloss.NewStyle().Width(infoWidth).Foreground(v.Theme.BrightCyan()).Render(info), ) v.listRect = ui.Rect{} - footer := "[p] Play | [esc] Back" + v.rail = ui.AlphabetRail{} + footer := "[p] Play | [v] Player | [esc] Back" if v.Metadata.Type == "track" { header += "\n\n" + renderBadges(v.Metadata, v.Theme) header = v.RenderPosterAndInfo(header) + } else if v.Metadata.Type == "artist" { + rect := ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} + rect.Height = max(1, ui.GetLayout().ContentHeight()-rect.Y-3) + v.rail = ui.NewAlphabetRail(2+width, rect.Y, rect.Height) + rect.Width -= v.rail.Width + if rect != v.albumsRect { + v.albumsRect = rect + v.albums.SetSize(rect.Width, rect.Height) + } + active := "" + if meta := v.GetSelectedMetadata(); meta != nil { + active = ui.AlphabetLetter(*meta) + } + header += "\n\n" + lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(rect.Width).MaxWidth(rect.Width).Render(v.albums.View()), + v.rail.View(active, v.Theme)) + footer = "[g] A–Z | [p] Play | [v] Player | [esc] Back" } else { v.listRect = ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} - v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-4) - v.items.SetSize(width, v.listRect.Height) - header += "\n\n" + v.items.View() - footer = "[enter] Open | [p] Play | [esc] Back" + v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-3) + v.rail = ui.NewAlphabetRail(2+width, v.listRect.Y, v.listRect.Height) + v.listRect.Width -= v.rail.Width + coverWidth := detailPosterWidth(v.Metadata) + cover := v.Poster + if v.posterWidth != coverWidth || cover == "" { + cover = lipgloss.NewStyle().Width(coverWidth).Height((coverWidth+1)/2). + Align(lipgloss.Center, lipgloss.Center).Render("♫") + } + cover = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(v.Theme.BrightBlack()).Width(coverWidth).Render(cover) + left := "" + if v.listRect.Width >= coverWidth+2+2+18 { + left = lipgloss.NewStyle().MarginRight(2).Render(cover) + v.listRect.X += coverWidth + 4 + v.listRect.Width -= coverWidth + 4 + } else if v.listRect.Height >= lipgloss.Height(cover)+6 { + // In very narrow panes the cover sits above a full-width track list. + header += "\n\n" + cover + v.listRect.Y += lipgloss.Height(cover) + 2 + v.listRect.Height -= lipgloss.Height(cover) + 2 + v.rail = ui.NewAlphabetRail(2+width, v.listRect.Y, v.listRect.Height) + v.listRect.Width = width - v.rail.Width + } + v.items.SetSize(max(1, v.listRect.Width), v.listRect.Height) + active := "" + if meta := v.GetSelectedMetadata(); meta != nil { + active = ui.AlphabetLetter(*meta) + } + tracks := lipgloss.NewStyle().Width(v.listRect.Width).MaxWidth(v.listRect.Width).Render(v.items.View()) + header += "\n\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, tracks, v.rail.View(active, v.Theme)) + footer = "[g] A–Z | [p] Play | [v] Player | [esc] Back" + } + if v.jumpNote != "" { + footer = v.jumpNote + " | [esc] Back" } return lipgloss.NewStyle().Padding(1, 2).Render(header) + "\n\n " + lipgloss.NewStyle().Foreground(v.Theme.BrightBlack()).Render(footer) diff --git a/internal/tui/view/detail/music_detail_test.go b/internal/tui/view/detail/music_detail_test.go index 999a959..ce9eccf 100644 --- a/internal/tui/view/detail/music_detail_test.go +++ b/internal/tui/view/detail/music_detail_test.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" "github.com/ygelfand/plexctl/internal/ui" ) @@ -67,3 +68,105 @@ func TestMusicNavigationAndMouse(t *testing.T) { t.Fatal("back from artist did not return to the library") } } + +func TestArtistShowsAlbumArtworkAndSelectsCover(t *testing.T) { + ui.GetLayout().Update(100, 40, false) + artist := NewMusicDetailView("artist", ui.PlexctlTheme) + artist.Update(detailDataMsg{ + metadata: &components.Metadata{Type: "artist", Title: "Artist"}, + children: []components.Metadata{ + {RatingKey: ui.Ptr("a1"), Type: "album", Title: "First album", Year: ui.Ptr(2024)}, + {RatingKey: ui.Ptr("a2"), Type: "album", Title: "Second album", Year: ui.Ptr(2026)}, + }, + }) + artist.Update(poster.PosterLoadedMsg{Index: 0, RatingKey: "a1", View: "FIRST COVER"}) + artist.Update(poster.PosterLoadedMsg{Index: 1, RatingKey: "a2", View: "SECOND COVER"}) + view := ansi.Strip(artist.View()) + if !strings.Contains(view, "SECOND COVER") || !strings.Contains(view, "2026") { + t.Fatal("artist page did not show album artwork and year") + } + _, cmd := artist.Update(tea.MouseMsg{ + X: artist.albumsRect.X + ui.PosterStepWidth + 2, Y: artist.albumsRect.Y + 3, + Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + if cmd == nil { + t.Fatal("album cover click did not open an album") + } + artist.Update(cmd()) + if artist.selected == nil || artist.selected.RatingKey != "a2" { + t.Fatal("album cover click selected the wrong album") + } + artist.Update(BackMsg{}) + if selected := artist.GetSelectedMetadata(); selected == nil || ui.PtrToString(selected.RatingKey) != "a2" { + t.Fatal("returning from an album lost the gallery selection") + } +} + +func TestAlbumArtworkBesideClickableTracks(t *testing.T) { + for _, size := range [][2]int{{140, 45}, {83, 49}, {80, 24}} { + ui.GetLayout().Update(size[0], size[1], false) + album := NewMusicDetailView("album", ui.PlexctlTheme) + album.Update(detailDataMsg{metadata: &components.Metadata{Type: "album", Title: "My album"}, + children: []components.Metadata{{RatingKey: ui.Ptr("song"), Type: "track", Title: "Zephyr"}}}) + album.Update(PosterDataMsg{ratingKey: "album", width: detailPosterWidth(album.Metadata), view: "COVER"}) + view := ansi.Strip(album.View()) + if !strings.Contains(view, "COVER") || !strings.Contains(view, "Zephyr") { + t.Fatalf("%v: missing artwork or tracks:\n%s", size, view) + } + for y, line := range strings.Split(view, "\n") { + if x := strings.Index(line, "Zephyr"); x >= 0 { + album.Update(tea.MouseMsg{X: lipgloss.Width(line[:x]), Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + } + } + if album.selected == nil || album.selected.RatingKey != "song" { + t.Fatalf("%v: cover shifted the track's mouse target (%+v):\n%s", size, album.listRect, view) + } + album.Update(PosterDataMsg{ratingKey: "album", width: detailPosterWidth(album.Metadata), view: "NEW COVER"}) + if album.Poster != "NEW COVER" || album.selected.Poster != "" { + t.Fatal("late parent artwork was applied to the selected track") + } + } +} + +func TestAlbumLettersUseTitlesAndPreserveTrackOrder(t *testing.T) { + ui.GetLayout().Update(100, 40, false) + artist := NewMusicDetailView("artist", ui.PlexctlTheme) + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "artist", Title: "Artist"}, + children: []components.Metadata{ + {RatingKey: ui.Ptr("z"), Type: "album", Title: "Zebra"}, + {RatingKey: ui.Ptr("a"), Type: "album", Title: "Amber"}, + }}) + artist.JumpToLetter("Z") + if artist.GetSelectedMetadata().Title != "Zebra" || artist.albums.Cursor != 1 { + t.Fatal("artist albums were not sorted or jumped to by title") + } + artist.Update(ui.KeyPress("enter")) + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "album", Title: "Zebra"}, + children: []components.Metadata{ + {RatingKey: ui.Ptr("song1"), Type: "track", Title: "Zulu", Index: ui.Ptr(1)}, + {RatingKey: ui.Ptr("song2"), Type: "track", Title: "Amber", Index: ui.Ptr(2)}, + }}) + artist.JumpToLetter("A") + if artist.GetSelectedMetadata().Title != "Amber" || artist.selected.items.Index() != 1 { + t.Fatal("track jump did not use the song title independently of track number") + } +} + +func TestCompactAlbumFitsArtworkTracksAlphabetAndFooter(t *testing.T) { + ui.GetLayout().SetPlayerHeight(8) + ui.GetLayout().Update(80, 24, true) + defer ui.GetLayout().Update(80, 24, false) + album := NewMusicDetailView("album", ui.PlexctlTheme) + var tracks []components.Metadata + for i := range 200 { + tracks = append(tracks, components.Metadata{Type: "track", Title: fmt.Sprintf("Track %d", i)}) + } + album.Update(detailDataMsg{metadata: &components.Metadata{Type: "album", Title: "Album"}, children: tracks}) + view := album.View() + if lipgloss.Height(view) > ui.GetLayout().ContentHeight() || lipgloss.Width(view) > ui.GetLayout().InnerWidth() { + t.Fatalf("compact album overflows its viewport: %dx%d", lipgloss.Width(view), lipgloss.Height(view)) + } + if !strings.Contains(view, "[esc] Back") || album.rail.Width == 0 { + t.Fatal("compact album lost its footer or mouse alphabet") + } +} diff --git a/internal/tui/view/home.go b/internal/tui/view/home.go index b6eec3a..c9c3b1e 100644 --- a/internal/tui/view/home.go +++ b/internal/tui/view/home.go @@ -35,6 +35,18 @@ func NewHomeView(theme tint.Tint) *HomeView { type homeHubsMsg []components.Hub +func (v *HomeView) CanJumpLetters() bool { + target, ok := v.details.View.(ui.LetterJumper) + return ok && target.CanJumpLetters() +} + +func (v *HomeView) JumpToLetter(letter string) tea.Cmd { + if target, ok := v.details.View.(ui.LetterJumper); ok { + return target.JumpToLetter(letter) + } + return nil +} + func (v *HomeView) Init() tea.Cmd { return v.fetchHubs } @@ -58,6 +70,11 @@ func (v *HomeView) fetchHubs() tea.Msg { } func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if posterMsg, ok := msg.(poster.PosterLoadedMsg); ok && v.details.Active() { + for _, list := range v.lists { + list.Update(posterMsg) + } + } if v.details.Active() { cmd, handled := v.details.Update(msg) if handled { diff --git a/internal/tui/view/media.go b/internal/tui/view/media.go index c463fb4..8d01464 100644 --- a/internal/tui/view/media.go +++ b/internal/tui/view/media.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "log/slog" + "strings" + "sync/atomic" + "time" "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" @@ -37,12 +40,21 @@ type MediaView struct { totalItems int loadedItems int isLoading bool + hasMore bool + requestID uint64 allMetadata []components.Metadata viewMode ViewMode + rail ui.AlphabetRail + contentRect ui.Rect + jumpLetter string + jumpEnd bool + jumpNote string details detail.DetailManager } +var pageRequestID atomic.Uint64 + func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { columns := []table.Column{ {Title: "W", Width: 3}, @@ -83,6 +95,8 @@ func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { theme: theme, viewMode: vMode, allMetadata: []components.Metadata{}, + totalItems: -1, + hasMore: true, } } @@ -102,16 +116,30 @@ func (v *MediaView) Refresh() tea.Cmd { } v.allMetadata = nil v.loadedItems = 0 + v.err = nil + v.jumpLetter, v.jumpNote, v.jumpEnd = "", "", false + v.totalItems = -1 + v.hasMore = true + v.table.SetRows(nil) + v.posterGrid.SetItems(nil) + v.posterGrid.SetCursor(0) + v.table.SetCursor(0) v.isLoading = true return v.fetchPage(0) } func (v *MediaView) fetchPage(start int) tea.Cmd { + sectionID, requestID := v.sectionID, pageRequestID.Add(1) + v.requestID = requestID return func() tea.Msg { - slog.Debug("MediaView: fetchPage started", "section", v.sectionID, "start", start) + page := ui.MediaPageMsg{SectionID: sectionID, RequestID: requestID, Start: start, Total: -1} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + slog.Debug("MediaView: fetchPage started", "section", sectionID, "start", start) client, err := plex.NewClient() if err != nil { - return err + page.Err = err + return page } cfg := config.Get() @@ -119,14 +147,15 @@ func (v *MediaView) fetchPage(start int) tea.Cmd { cm, _ := cache.Get(cfg.CacheDir) req := operations.ListContentRequest{ - SectionID: v.sectionID, + SectionID: sectionID, XPlexContainerStart: ui.Ptr(start), XPlexContainerSize: ui.Ptr(100), + MediaQuery: &components.MediaQuery{Sort: ui.Ptr("titleSort:asc")}, } var body components.MediaContainerWithMetadata err = cache.AutoCache(cm, serverID, req, plex.LibraryCacheTTL, &body, func() (*components.MediaContainerWithMetadata, error) { - res, err := client.SDK.Content.ListContent(context.Background(), req) + res, err := client.SDK.Content.ListContent(ctx, req) if err != nil { return nil, err } @@ -136,68 +165,89 @@ func (v *MediaView) fetchPage(start int) tea.Cmd { return res.MediaContainerWithMetadata, nil }) if err != nil { - slog.Error("MediaView fetch error", "error", err) - return err + page.Err = err + return page } if body.MediaContainer == nil { - return fmt.Errorf("no media container found") + page.Err = fmt.Errorf("no media container found") + return page } - - total := 0 if body.MediaContainer.TotalSize != nil { - total = int(*body.MediaContainer.TotalSize) - } else { - total = len(body.MediaContainer.Metadata) + page.Total = int(*body.MediaContainer.TotalSize) } - - slog.Debug("MediaView: fetchPage finished", "section", v.sectionID, "count", len(body.MediaContainer.Metadata), "total", total) - - return ui.MediaPageMsg{Metadata: body.MediaContainer.Metadata, Total: total} + page.Metadata = body.MediaContainer.Metadata + return page } } func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if posterMsg, ok := msg.(poster.PosterLoadedMsg); ok { + v.posterGrid.Update(posterMsg) + } if v.details.Active() { + var parentCmd tea.Cmd // Parent-level updates still happen even if detail is active (like window size) switch m := msg.(type) { case tea.WindowSizeMsg: - v.table.SetHeight(ui.GetTableHeight(ui.GetLayout().TotalHeight())) - v.table.SetWidth(ui.GetLayout().MainAreaContentWidth()) - v.posterGrid.Update(m) + v.updateBounds() + parentCmd = v.posterGrid.Init() case ui.ThemeChangedMsg: v.theme = m.Theme v.posterGrid.Theme = v.theme - ui.UpdateTableTheme(&v.table, ui.GetLayout().MainAreaContentWidth(), ui.GetLayout().TotalHeight()) + ui.UpdateTableTheme(&v.table, v.contentRect.Width, ui.GetLayout().TotalHeight()) + v.table.SetHeight(v.contentRect.Height) } cmd, handled := v.details.Update(msg) if handled { - return v, cmd + return v, tea.Batch(parentCmd, cmd) } } var cmds []tea.Cmd - if mouse, ok := msg.(tea.MouseMsg); ok && v.viewMode == ViewModeList && v.table.Click(mouse) { - msg = ui.KeyPress("enter") + if mouse, ok := msg.(tea.MouseMsg); ok { + if letter := v.rail.LetterAt(mouse); letter != "" { + return v, v.JumpToLetter(letter) + } + if _, inside := v.contentRect.Local(mouse); !inside { + return v, nil + } + if v.viewMode == ViewModeList && v.table.Click(mouse) { + msg = ui.KeyPress("enter") + } } switch msg := msg.(type) { case tea.WindowSizeMsg: - v.table.SetHeight(ui.GetTableHeight(ui.GetLayout().TotalHeight())) - v.table.SetWidth(ui.GetLayout().MainAreaContentWidth()) - v.posterGrid.Update(msg) + v.updateBounds() + cmds = append(cmds, v.posterGrid.Init()) + return v, tea.Batch(cmds...) case ui.ThemeChangedMsg: v.theme = msg.Theme v.posterGrid.Theme = v.theme - ui.UpdateTableTheme(&v.table, ui.GetLayout().MainAreaContentWidth(), ui.GetLayout().TotalHeight()) + ui.UpdateTableTheme(&v.table, v.contentRect.Width, ui.GetLayout().TotalHeight()) + v.table.SetHeight(v.contentRect.Height) case ui.MediaPageMsg: + if msg.SectionID != v.sectionID || msg.RequestID != v.requestID || msg.Start != v.loadedItems { + return v, nil + } v.isLoading = false + v.err = msg.Err + if msg.Err != nil { + return v, nil + } v.totalItems = msg.Total v.allMetadata = append(v.allMetadata, msg.Metadata...) v.loadedItems = len(v.allMetadata) + v.hasMore = len(msg.Metadata) > 0 && (msg.Total < 0 || v.loadedItems < msg.Total) v.syncTableRows() cmds = append(cmds, v.posterGrid.SetItems(v.allMetadata)) + if v.jumpLetter != "" || v.jumpEnd { + cmds = append(cmds, v.advanceJump()) + } else { + cmds = append(cmds, v.loadMore()) + } return v, tea.Batch(cmds...) case poster.PosterLoadedMsg: _, cmd := v.posterGrid.Update(msg) @@ -210,14 +260,22 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.err = msg case tea.KeyMsg: switch msg.String() { + case "home": + v.jumpLetter, v.jumpNote, v.jumpEnd = "", "", false + return v, v.selectIndex(0) + case "end": + v.jumpLetter, v.jumpNote, v.jumpEnd = "", "", true + return v, v.advanceJump() + case "esc": + v.jumpLetter, v.jumpNote, v.jumpEnd = "", "", false + return v, nil case "V": cfg := config.Get() id, server, ok := cfg.GetActiveServer() if v.viewMode == ViewModeList { v.viewMode = ViewModePoster - v.posterGrid.Cursor = v.table.Cursor() - cmds = append(cmds, v.posterGrid.Init()) + cmds = append(cmds, v.posterGrid.SetCursor(v.table.Cursor())) } else { v.viewMode = ViewModeList v.table.SetCursor(v.posterGrid.Cursor) @@ -252,6 +310,11 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, v.ShowDetail(ratingKey, meta.Type) } case "r": + if v.err != nil { + v.err = nil + v.isLoading = true + return v, v.fetchPage(v.loadedItems) + } return v, v.Refresh() } } @@ -264,20 +327,90 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, cmd) - // Lazy loading - if !v.isLoading && v.loadedItems > 0 && v.loadedItems < v.totalItems { - currIdx := v.table.Cursor() - if v.viewMode == ViewModePoster { - currIdx = v.posterGrid.Cursor + cmds = append(cmds, v.loadMore()) + + return v, tea.Batch(cmds...) +} + +func (v *MediaView) loadMore() tea.Cmd { + if v.isLoading || v.err != nil || !v.hasMore || v.details.Active() { + return nil + } + index := v.table.Cursor() + if v.viewMode == ViewModePoster { + index = max(v.posterGrid.Cursor, v.posterGrid.LastVisibleIndex()) + } + if index >= v.loadedItems-20 { + v.isLoading = true + return v.fetchPage(v.loadedItems) + } + return nil +} + +func (v *MediaView) CanJumpLetters() bool { + if v.details.Active() { + target, ok := v.details.View.(ui.LetterJumper) + return ok && target.CanJumpLetters() + } + return true +} + +func (v *MediaView) JumpToLetter(letter string) tea.Cmd { + if v.details.Active() { + if target, ok := v.details.View.(ui.LetterJumper); ok { + return target.JumpToLetter(letter) } + return nil + } + letter = strings.ToUpper(letter) + if len(letter) != 1 || !strings.Contains(ui.AlphabetLetters, letter) { + return nil + } + v.jumpLetter, v.jumpNote, v.jumpEnd = letter, "", false + return v.advanceJump() +} - if currIdx > v.loadedItems-20 { - v.isLoading = true - cmds = append(cmds, v.fetchPage(v.loadedItems)) +func (v *MediaView) selectIndex(index int) tea.Cmd { + v.table.SetCursor(index) + return v.posterGrid.SetCursor(index) +} + +// A jump reuses already fetched pages, then loads missing metadata as needed. +// Only visible artwork is fetched; repeated jumps are immediate once loaded. +func (v *MediaView) advanceJump() tea.Cmd { + if v.jumpLetter != "" { + if i := ui.AlphabetIndex(v.allMetadata, v.jumpLetter); i >= 0 { + v.jumpLetter = "" + return v.selectIndex(i) + } + } + if !v.hasMore && !v.isLoading { + if v.jumpEnd { + v.jumpEnd = false + return v.selectIndex(max(0, v.loadedItems-1)) } + v.jumpNote = "No titles under " + v.jumpLetter + v.jumpLetter = "" + return nil } + if !v.isLoading && v.err == nil { + v.isLoading = true + return v.fetchPage(v.loadedItems) + } + return nil +} - return v, tea.Batch(cmds...) +func (v *MediaView) updateBounds() { + layout := ui.GetLayout() + height := max(1, layout.ContentHeight()-1) + v.rail = ui.NewAlphabetRail(layout.InnerWidth(), 0, height) + rect := ui.Rect{Width: max(1, layout.InnerWidth()-v.rail.Width), Height: height} + if rect != v.contentRect { + v.contentRect = rect + v.table.SetHeight(rect.Height) + v.table.SetWidth(rect.Width) + v.posterGrid.SetSize(rect.Width, rect.Height) + } } func (v *MediaView) GetSectionID() string { @@ -333,8 +466,8 @@ func (v *MediaView) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "enter", Desc: "View Details"}, {Key: "V", Desc: "Toggle List / Poster View"}, - {Key: "j/up", Desc: "Move Up"}, - {Key: "k/down", Desc: "Move Down"}, + {Key: "k/up", Desc: "Move Up"}, + {Key: "j/down", Desc: "Move Down"}, } } @@ -342,19 +475,51 @@ func (v *MediaView) View() string { if v.details.Active() { return v.details.ViewContent() } - + if v.err != nil && v.loadedItems == 0 { + return fmt.Sprintf("Unable to load this page: %v\nPress r to retry.", v.err) + } + v.updateBounds() + content := "" if v.viewMode == ViewModeList { - return lipgloss.NewStyle().Render(v.table.View()) + content = v.table.View() + } else { + content = v.posterGrid.View() } - - return v.posterGrid.View() + active := v.jumpLetter + if active == "" { + if meta := v.GetSelectedMetadata(); meta != nil { + active = ui.AlphabetLetter(*meta) + } + } + body := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(v.contentRect.Width).Height(v.contentRect.Height). + MaxWidth(v.contentRect.Width).MaxHeight(v.contentRect.Height).Render(content), + v.rail.View(active, v.theme)) + count := fmt.Sprintf("%d loaded", v.loadedItems) + if v.totalItems >= 0 { + count = fmt.Sprintf("%d / %d", v.loadedItems, v.totalItems) + } + status := v.jumpNote + switch { + case v.err != nil: + status = "Load failed · [r] Retry" + case v.jumpLetter != "": + status = "Finding " + v.jumpLetter + " · [esc] Cancel" + case v.jumpEnd: + status = "Finding last item · [esc] Cancel" + case v.isLoading: + status = "Loading…" + } + footer := count + " · [g] A–Z" + if status != "" { + footer += " · " + status + } + return body + "\n" + lipgloss.NewStyle().Foreground(v.theme.BrightBlack()). + Render(ui.Ellipsis(footer, ui.GetLayout().InnerWidth())) } func (v *MediaView) ActionRows() int { - if v.details.Active() { - return 1 - } - return 0 + return 1 } func (v *MediaView) syncTableRows() { diff --git a/internal/tui/view/media_test.go b/internal/tui/view/media_test.go new file mode 100644 index 0000000..237df04 --- /dev/null +++ b/internal/tui/view/media_test.go @@ -0,0 +1,154 @@ +package view + +import ( + "fmt" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/ui" +) + +func artistPage(start, count int) []components.Metadata { + items := make([]components.Metadata, count) + for i := range items { + items[i] = components.Metadata{RatingKey: ui.Ptr(fmt.Sprint(start + i)), Title: "Artist", Type: "artist"} + } + return items +} + +func TestLetterJumpLoadsMissingPagesAndPreservesSelectionOnFailure(t *testing.T) { + ui.GetLayout().Update(100, 35, false) + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.viewMode = ViewModePoster + v.Update(tea.WindowSizeMsg{Width: 100, Height: 35}) + v.Init() + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Metadata: artistPage(0, 100), Total: 237}) + v.JumpToLetter("Z") + if !v.isLoading || v.jumpLetter != "Z" { + t.Fatal("jump did not request missing metadata") + } + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 100, Err: fmt.Errorf("offline")}) + if v.posterGrid.Cursor != 0 || !strings.Contains(v.View(), "Retry") { + t.Fatal("failed jump lost the library or its selection") + } + v.Update(ui.KeyPress("r")) + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 100, Metadata: artistPage(100, 100), Total: 237}) + if !v.isLoading { + t.Fatal("jump stopped before reaching the requested letter") + } + last := artistPage(200, 37) + last[36].Title = "Zephyr" + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 200, Metadata: last, Total: 237}) + if v.posterGrid.Cursor != 236 || v.table.Cursor() != 236 || v.jumpLetter != "" || v.hasMore { + t.Fatal("jump did not select the first matching artist on the last page") + } + if !strings.Contains(v.View(), "Zephyr") { + t.Fatal("jump selected an artist without scrolling it into view") + } + v.JumpToLetter("B") + if v.posterGrid.Cursor != 236 || !strings.Contains(v.View(), "No titles under B") { + t.Fatal("missing letter moved the selection or gave no feedback") + } + v.Update(ui.KeyPress("home")) + if v.posterGrid.Cursor != 0 { + t.Fatal("Home did not restore the first item") + } +} + +func TestLetterJumpCanBeChangedOrCancelledWhileLoading(t *testing.T) { + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.Init() + v.JumpToLetter("Z") + v.JumpToLetter("B") + page := artistPage(0, 100) + page[50].Title = "B artist" + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Metadata: page, Total: 200}) + if v.table.Cursor() != 50 || v.isLoading { + t.Fatal("an earlier jump overrode the latest destination") + } + v.JumpToLetter("Z") + v.Update(ui.KeyPress("esc")) + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 100, Metadata: artistPage(100, 100), Total: 300}) + if v.isLoading || v.table.Cursor() != 50 || v.jumpLetter != "" { + t.Fatal("cancelled jump kept paging or changed selection") + } +} + +func TestLibraryEndLoadsFinalPage(t *testing.T) { + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.Init() + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Metadata: artistPage(0, 100), Total: 110}) + v.Update(ui.KeyPress("end")) + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 100, Metadata: artistPage(100, 10), Total: 110}) + if v.table.Cursor() != 109 || v.jumpEnd || v.isLoading { + t.Fatal("End did not reach the final library item") + } +} + +func TestMousePaginationFollowsViewport(t *testing.T) { + ui.GetLayout().Update(100, 35, false) + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.viewMode = ViewModePoster + v.Update(tea.WindowSizeMsg{Width: 100, Height: 35}) + v.Init() + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Metadata: artistPage(0, 100), Total: 237}) + for _, start := range []int{100, 200} { + v.View() + for i := 0; !v.isLoading && i < 300; i++ { + v.Update(tea.MouseMsg{X: 5, Y: 5, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + } + if !v.isLoading || v.posterGrid.Cursor != 0 { + t.Fatalf("scrolling did not load page %d independently of selection", start) + } + requestID := v.requestID + v.Update(ui.KeyPress("down")) + if v.requestID != requestID { + t.Fatal("issued a duplicate page request while loading") + } + v.posterGrid.Cursor = 0 + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: requestID, Start: start, + Metadata: artistPage(start, min(100, 237-start)), Total: 237}) + } + if v.loadedItems != 237 || v.hasMore { + t.Fatalf("last page: loaded %d, hasMore %v", v.loadedItems, v.hasMore) + } +} + +func TestPaginationIgnoresStaleResponsesAndAllowsRetry(t *testing.T) { + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.Init() + oldID := v.requestID + v.Refresh() + for _, response := range []ui.MediaPageMsg{ + {SectionID: "other", RequestID: v.requestID, Metadata: artistPage(0, 10)}, + {SectionID: "music", RequestID: oldID, Metadata: artistPage(0, 10)}, + } { + v.Update(response) + } + if v.loadedItems != 0 || !v.isLoading { + t.Fatal("a stale page changed the refreshed library") + } + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Err: fmt.Errorf("offline")}) + if v.isLoading || v.err == nil { + t.Fatal("page failure left the library loading") + } + v.Update(ui.KeyPress("r")) + if !v.isLoading || v.err != nil { + t.Fatal("retry did not restart the failed page") + } +} + +func TestUnknownTotalContinuesUntilEmptyPage(t *testing.T) { + v := NewMediaView("music", "Music", ui.PlexctlTheme) + v.Init() + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Metadata: artistPage(0, 7), Total: -1}) + if !v.hasMore || !v.isLoading { + t.Fatal("short page without total prematurely ended pagination") + } + v.Update(ui.MediaPageMsg{SectionID: "music", RequestID: v.requestID, Start: 7, Total: -1}) + if v.hasMore || v.isLoading || v.loadedItems != 7 { + t.Fatal("empty page did not finish pagination") + } +} diff --git a/internal/tui/view/search_status.go b/internal/tui/view/search_status.go index b2c7ecb..b8c42fb 100644 --- a/internal/tui/view/search_status.go +++ b/internal/tui/view/search_status.go @@ -41,15 +41,29 @@ func (v *SearchStatusView) Init() tea.Cmd { } func (v *SearchStatusView) Refresh() tea.Cmd { + if v.isIndexing { + return nil + } v.isIndexing = true v.err = nil v.progress = search.IndexProgress{} v.progressChan = make(chan search.IndexProgress, 100) - return tea.Batch(v.spinner.Tick, v.runReindex, v.waitForProgress()) + progress := v.progressChan + return tea.Batch(v.spinner.Tick, func() tea.Msg { + defer close(progress) + err := search.GetIndex().Reindex(context.Background(), progress) + return SearchIndexFinishedMsg{owner: v, err: err} + }, v.waitForProgress()) } -type reindexProgressMsg search.IndexProgress -type reindexFinishedMsg struct{ err error } +type SearchIndexProgressMsg struct { + owner *SearchStatusView + progress search.IndexProgress +} +type SearchIndexFinishedMsg struct { + owner *SearchStatusView + err error +} func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -59,10 +73,16 @@ func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ui.ThemeChangedMsg: v.theme = msg.Theme v.spinner.Style = lipgloss.NewStyle().Foreground(ui.Accent(v.theme)) - case reindexProgressMsg: - v.progress = search.IndexProgress(msg) + case SearchIndexProgressMsg: + if msg.owner != v || !v.isIndexing { + return v, nil + } + v.progress = msg.progress return v, v.waitForProgress() - case reindexFinishedMsg: + case SearchIndexFinishedMsg: + if msg.owner != v { + return v, nil + } v.isIndexing = false v.err = msg.err v.progressChan = nil @@ -86,29 +106,23 @@ func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil } -func (v *SearchStatusView) runReindex() tea.Msg { - ctx := context.Background() - idx := search.GetIndex() - err := idx.Reindex(ctx, v.progressChan) - v.progressChan <- search.IndexProgress{Message: "DONE"} - return reindexFinishedMsg{err: err} -} - func (v *SearchStatusView) waitForProgress() tea.Cmd { + progress := v.progressChan return func() tea.Msg { - if v.progressChan == nil { + if progress == nil { return nil } - p, ok := <-v.progressChan - if !ok || p.Message == "DONE" { - return nil // Let runReindex return the finished msg + p, ok := <-progress + if !ok { + return nil } - return reindexProgressMsg(p) + return SearchIndexProgressMsg{owner: v, progress: p} } } func (v *SearchStatusView) View() string { idx := search.GetIndex() + entries, indexedAt := idx.Snapshot() titleStyle := ui.TitleStyle(v.theme) labelStyle := ui.LabelStyle(v.theme) @@ -119,12 +133,12 @@ func (v *SearchStatusView) View() string { lines = append(lines, "") lastIndexed := "Never" - if !idx.LastIndexed.IsZero() { - lastIndexed = idx.LastIndexed.Format("2006-01-02 15:04:05") + if !indexedAt.IsZero() { + lastIndexed = indexedAt.Format("2006-01-02 15:04:05") } lines = append(lines, labelStyle.Render("Last Indexed: ")+valueStyle.Render(lastIndexed)) - lines = append(lines, labelStyle.Render("Total Entries: ")+valueStyle.Render(fmt.Sprintf("%d", len(idx.Entries)))) + lines = append(lines, labelStyle.Render("Total Entries: ")+valueStyle.Render(fmt.Sprintf("%d", len(entries)))) lines = append(lines, "") lines = append(lines, titleStyle.Render("LIBRARY STATISTICS")) diff --git a/internal/tui/widget/letterjump/letterjump.go b/internal/tui/widget/letterjump/letterjump.go new file mode 100644 index 0000000..41d03da --- /dev/null +++ b/internal/tui/widget/letterjump/letterjump.go @@ -0,0 +1,61 @@ +package letterjump + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/ui" +) + +// Model captures the second key of g + letter, so letters used for playback or +// quitting remain available as destinations without triggering those actions. +type Model struct { + theme tint.Tint + rail ui.AlphabetRail + choice int +} + +func New(theme tint.Tint) *Model { + return &Model{theme: theme, rail: ui.NewAlphabetRail(12, 5, 9)} +} + +func (m *Model) Init() tea.Cmd { return nil } + +func (m *Model) choose(letter string) (tea.Model, tea.Cmd) { + return nil, func() tea.Msg { return ui.JumpToLetterMsg{Letter: letter} } +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.MouseMsg: + if letter := m.rail.LetterAt(msg); letter != "" { + return m.choose(letter) + } + case tea.KeyMsg: + key := strings.ToUpper(msg.String()) + if len(key) == 1 && strings.Contains(ui.AlphabetLetters, key) { + return m.choose(key) + } + switch msg.String() { + case "esc", "ctrl+c": + return nil, nil + case "up", "left": + m.choice = (m.choice + 26) % 27 + case "down", "right", "tab": + m.choice = (m.choice + 1) % 27 + case "enter": + return m.choose(ui.AlphabetLetters[m.choice : m.choice+1]) + } + } + return m, nil +} + +func (m *Model) View() string { + active := ui.AlphabetLetters[m.choice : m.choice+1] + content := "Jump to letter\nType A–Z or #\n\n" + m.rail.View(active, m.theme) + + "\n\n# Numbers / other\nEsc Cancel" + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(ui.Accent(m.theme)).Padding(1, 2).Render(content) +} diff --git a/internal/tui/widget/letterjump/letterjump_test.go b/internal/tui/widget/letterjump/letterjump_test.go new file mode 100644 index 0000000..4881cd6 --- /dev/null +++ b/internal/tui/widget/letterjump/letterjump_test.go @@ -0,0 +1,20 @@ +package letterjump + +import ( + "testing" + + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestPlaybackAndQuitKeysRemainLetterDestinations(t *testing.T) { + for _, key := range []string{"p", "q", "m", "z", "#"} { + m := New(ui.PlexctlTheme) + model, cmd := m.Update(ui.KeyPress(key)) + if model != nil || cmd == nil { + t.Fatalf("%s did not choose a letter", key) + } + if _, ok := cmd().(ui.JumpToLetterMsg); !ok { + t.Fatalf("%s activated a non-navigation action", key) + } + } +} diff --git a/internal/tui/widget/playback/playback.go b/internal/tui/widget/playback/playback.go index 0871798..e2f810d 100644 --- a/internal/tui/widget/playback/playback.go +++ b/internal/tui/widget/playback/playback.go @@ -12,10 +12,12 @@ import ( "github.com/ygelfand/plexctl/internal/ui" ) -var choices = []struct { +type choice struct { label string output config.VideoOutput -}{ +} + +var choices = []choice{ {"Automatic", config.VideoAuto}, {"Kitty graphics", config.VideoKitty}, {"Sixel graphics", config.VideoSixel}, @@ -28,10 +30,12 @@ type Model struct { theme tint.Tint choice int width int + choices []choice + music bool } func New(ratingKey, title string, theme tint.Tint) *Model { - m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58} + m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58, choices: choices} for i, choice := range choices { if choice.output == config.Get().TerminalVideo { m.choice = i @@ -40,6 +44,16 @@ func New(ratingKey, title string, theme tint.Tint) *Model { return m } +func NewMusic(ratingKey, title string, theme tint.Tint) *Model { + m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58, music: true, + choices: []choice{{"In this terminal · audio + visualizer", config.AudioTerminal}, + {"Separate player window", config.VideoWindow}}} + if config.Get().MusicOutput() == config.VideoWindow { + m.choice = 1 + } + return m +} + func (m *Model) Init() tea.Cmd { return nil } func (m *Model) ActionRows() int { return 1 } @@ -48,9 +62,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.MouseMsg: if delta := ui.WheelDelta(msg); delta != 0 { - m.choice = max(0, min(len(choices)-1, m.choice+delta)) + m.choice = max(0, min(len(m.choices)-1, m.choice+delta)) } else if ui.LeftClick(msg) { - for i, choice := range choices { + for i, choice := range m.choices { if ui.LabelAt(m.View(), msg, choice.label) { m.choice = i return m.Update(ui.KeyPress("enter")) @@ -62,13 +76,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "up", "k": - m.choice = (m.choice + len(choices) - 1) % len(choices) + m.choice = (m.choice + len(m.choices) - 1) % len(m.choices) case "down", "j": - m.choice = (m.choice + 1) % len(choices) + m.choice = (m.choice + 1) % len(m.choices) case "esc", "q": return nil, nil case "enter": - output := choices[m.choice].output + output := m.choices[m.choice].output return nil, func() tea.Msg { return ui.RequestPlayMsg{RatingKey: m.ratingKey, Output: output} } @@ -80,7 +94,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) View() string { accent := lipgloss.NewStyle().Foreground(ui.Accent(m.theme)).Bold(true) lines := []string{accent.Render("Play in terminal"), ui.Ellipsis(m.title, m.width-4), ""} - for i, choice := range choices { + if m.music { + lines[0] = accent.Render("Music player") + } + for i, choice := range m.choices { line := " " + choice.label if m.choice == i { line = accent.Render("> " + choice.label) @@ -106,6 +123,12 @@ func (m *Model) View() string { } lines = append(lines, "", lipgloss.NewStyle().Width(m.width-4).Render(detected), "", "Space / click: pause Arrows / wheel: seek", "Right click / q: return", "", "[enter] Play | [esc] Cancel") + if m.music { + lines = lines[:3+len(m.choices)] + lines = append(lines, "", "Browse your library while listening.", + "Space: pause · [ / ]: seek · + / -: volume", + "m: mute · z: visualizer · x: stop", "", "[enter] Play | [esc] Cancel") + } return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(ui.Accent(m.theme)).Padding(1, 2).Width(m.width). Render(strings.Join(lines, "\n")) diff --git a/internal/tui/widget/poster/grid.go b/internal/tui/widget/poster/grid.go index e130d03..ecd8830 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -1,6 +1,8 @@ package poster import ( + "strings" + "github.com/LukeHagar/plexgo/models/components" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -45,6 +47,36 @@ func (m *PosterGrid) Init() tea.Cmd { return m.fetchVisible() } +// SetCursor scrolls to an absolute item, including items on newly appended pages. +func (m *PosterGrid) SetCursor(index int) tea.Cmd { + m.Cursor = max(0, min(index, len(m.Items)-1)) + m.View() + return m.syncScroll() +} + +// SetSize lets embedded grids, such as an artist's albums, use their own +// content area instead of the entire library viewport. +func (m *PosterGrid) SetSize(width, height int) { + m.viewport.Width, m.viewport.Height = max(1, width), max(1, height) + m.View() + m.scrollToCursor() +} + +func (m *PosterGrid) columns() int { + width := m.viewport.Width + if width <= 0 { + width = ui.GetLayout().InnerWidth() + } + return max(1, width/ui.PosterStepWidth) +} + +// LastVisibleIndex follows the viewport even when the mouse scrolls without +// moving the keyboard selection. Libraries use it to load the next page. +func (m *PosterGrid) LastVisibleIndex() int { + rows := (m.viewport.YOffset + max(1, m.viewport.Height) + ui.PosterTotalHeight - 1) / ui.PosterTotalHeight + return min(len(m.Items)-1, rows*m.columns()-1) +} + func (m *PosterGrid) SetItems(metadata []components.Metadata) tea.Cmd { newItems := make([]*PosterItem, len(metadata)) for i, meta := range metadata { @@ -70,14 +102,13 @@ func (m *PosterGrid) SetItems(metadata []components.Metadata) tea.Cmd { } func (m *PosterGrid) fetchVisible() tea.Cmd { - layout := ui.GetLayout() - cols := layout.PosterColumns() + cols := m.columns() if cols <= 0 { return nil } var cmds []tea.Cmd - rowsVisible := layout.ContentHeight() / ui.PosterTotalHeight + rowsVisible := m.viewport.Height / ui.PosterTotalHeight if rowsVisible <= 0 { rowsVisible = 3 } @@ -90,7 +121,8 @@ func (m *PosterGrid) fetchVisible() tea.Cmd { } for i := startIdx; i < endIdx; i++ { - if m.Items[i].Loading { + if m.Items[i].Loading && !m.Items[i].Fetching { + m.Items[i].Fetching = true cmds = append(cmds, FetchPoster(0, i, m.Items[i].Metadata)) } } @@ -111,8 +143,12 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case PosterLoadedMsg: if msg.Index >= 0 && msg.Index < len(m.Items) { + if msg.RatingKey != ui.PtrToString(m.Items[msg.Index].Metadata.RatingKey) { + return m, nil + } m.Items[msg.Index].Poster = msg.View m.Items[msg.Index].Loading = false + m.Items[msg.Index].Fetching = false } case tea.MouseMsg: if _, inside := (ui.Rect{Width: m.viewport.Width, Height: m.viewport.Height}).Local(msg); !inside { @@ -123,7 +159,7 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.fetchVisible() } if ui.LeftClick(msg) { - cols := ui.GetLayout().PosterColumns() + cols := m.columns() column := msg.X / ui.PosterStepWidth row := (msg.Y + m.viewport.YOffset) / ui.PosterTotalHeight index := row*cols + column @@ -134,8 +170,16 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyMsg: - cols := ui.GetLayout().PosterColumns() + cols := m.columns() switch msg.String() { + case "home": + return m, m.SetCursor(0) + case "end": + return m, m.SetCursor(len(m.Items) - 1) + case "pgup", "ctrl+u": + return m, m.SetCursor(m.Cursor - max(1, m.viewport.Height/ui.PosterTotalHeight)*cols) + case "pgdown", "ctrl+d": + return m, m.SetCursor(m.Cursor + max(1, m.viewport.Height/ui.PosterTotalHeight)*cols) case "up", "k": if m.Cursor >= cols { m.Cursor -= cols @@ -169,8 +213,12 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *PosterGrid) syncScroll() tea.Cmd { - layout := ui.GetLayout() - cols := layout.PosterColumns() + m.scrollToCursor() + return m.fetchVisible() +} + +func (m *PosterGrid) scrollToCursor() { + cols := m.columns() row := m.Cursor / cols targetY := row * ui.PosterTotalHeight @@ -180,7 +228,6 @@ func (m *PosterGrid) syncScroll() tea.Cmd { m.viewport.SetYOffset(targetY - m.viewport.Height + ui.PosterTotalHeight) } - return m.fetchVisible() } func (m *PosterGrid) View() string { @@ -188,19 +235,24 @@ func (m *PosterGrid) View() string { return "" } - layout := ui.GetLayout() - cols := layout.PosterColumns() + cols := m.columns() - var rows []string - var currentRow []string - - for i, item := range m.Items { - currentRow = append(currentRow, RenderPosterItem(item, i == m.Cursor, true, m.Theme)) - - if len(currentRow) == cols || i == len(m.Items)-1 { - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, currentRow...)) - currentRow = nil + rowCount := (len(m.Items) + cols - 1) / cols + startRow := m.viewport.YOffset / ui.PosterTotalHeight + endRow := min(rowCount, (m.viewport.YOffset+max(1, m.viewport.Height)+ui.PosterTotalHeight-1)/ui.PosterTotalHeight) + rows := make([]string, rowCount) + // Only visible posters need rendering, even with thousands of artists and + // a 20 FPS audio visualizer. Blank offscreen rows retain scroll geometry. + for row := range rowCount { + if row < startRow || row >= endRow { + rows[row] = strings.Repeat("\n", ui.PosterTotalHeight-1) + continue + } + items := make([]string, 0, cols) + for i := row * cols; i < min((row+1)*cols, len(m.Items)); i++ { + items = append(items, RenderPosterItem(m.Items[i], i == m.Cursor, true, m.Theme)) } + rows[row] = lipgloss.JoinHorizontal(lipgloss.Top, items...) } m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Left, rows...)) diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index 119167b..df2bd28 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -6,6 +6,7 @@ import ( "fmt" "image" "strings" + "time" _ "image/jpeg" _ "image/png" @@ -23,12 +24,14 @@ type PosterItem struct { Metadata components.Metadata Poster string Loading bool + Fetching bool } type PosterLoadedMsg struct { - ListID int - Index int - View string + ListID int + Index int + View string + RatingKey string } type ItemSelectedMsg struct { @@ -42,10 +45,11 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { if metadata.RatingKey != nil { rk = *metadata.RatingKey } + result := PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk} if rk != "" { if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { - return PosterLoadedMsg{ListID: listID, Index: index, View: cached} + return PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk, View: cached} } } @@ -57,27 +61,39 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { } else if metadata.Thumb != nil { path = *metadata.Thumb } + if (metadata.Type == "artist" || metadata.Type == "album") && metadata.Thumb != nil { + path = *metadata.Thumb + } + if metadata.Type == "track" && metadata.ParentThumb != nil { + path = *metadata.ParentThumb + } if path == "" { - return nil + return result } - data, err := plex.GetImage(context.Background(), path) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + data, err := plex.GetImage(ctx, path) if err != nil { - return nil + return result } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - return nil + return result } - imgStr, err := artwork.Render(rk, img, ui.PosterWidth, ui.PosterHeight) + height := ui.PosterHeight + if metadata.Type == "album" || metadata.Type == "track" { + height = min(height, max(1, (ui.PosterWidth*img.Bounds().Dy()/img.Bounds().Dx()+1)/2)) + } + imgStr, err := artwork.Render(rk, img, ui.PosterWidth, height) if err != nil { - return nil + return result } - return PosterLoadedMsg{ListID: listID, Index: index, View: imgStr} + return PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk, View: imgStr} } } @@ -121,7 +137,9 @@ func RenderPosterItem(item *PosterItem, isSelected bool, active bool, theme tint } watched := "" - if !isSelected || !active { + if item.Metadata.Type == "album" && item.Metadata.Year != nil { + watched = lipgloss.NewStyle().Foreground(theme.BrightBlack()).Render(fmt.Sprint(*item.Metadata.Year)) + } else if !isSelected || !active { watched = RenderMiniWatched(&item.Metadata, theme) } diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 6108098..70e9104 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -86,7 +86,8 @@ func (m *PosterList) fetchVisible() tea.Cmd { end := min(m.Offset+cols+2, len(m.Items)) for i := start; i < end; i++ { - if m.Items[i].Loading { + if m.Items[i].Loading && !m.Items[i].Fetching { + m.Items[i].Fetching = true cmds = append(cmds, FetchPoster(m.ID, i, m.Items[i].Metadata)) } } @@ -97,8 +98,12 @@ func (m *PosterList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case PosterLoadedMsg: if msg.ListID == m.ID && msg.Index >= 0 && msg.Index < len(m.Items) { + if msg.RatingKey != ui.PtrToString(m.Items[msg.Index].Metadata.RatingKey) { + return m, nil + } m.Items[msg.Index].Poster = msg.View m.Items[msg.Index].Loading = false + m.Items[msg.Index].Fetching = false } return m, nil case tea.WindowSizeMsg: diff --git a/internal/tui/widget/search/overlay.go b/internal/tui/widget/search/overlay.go index eb8d35a..9deb241 100644 --- a/internal/tui/widget/search/overlay.go +++ b/internal/tui/widget/search/overlay.go @@ -1,8 +1,11 @@ package search import ( + "context" "fmt" "strings" + "sync/atomic" + "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" @@ -26,7 +29,7 @@ func (i searchResultItem) Title() string { return title } func (i searchResultItem) Description() string { - return fmt.Sprintf("[%s] %s", strings.ToUpper(i.entry.Type), i.entry.Library) + return i.entry.Description() } func (i searchResultItem) FilterValue() string { return i.entry.Title } @@ -37,11 +40,25 @@ type SearchOverlayModel struct { height int theme tint.Tint allEntries []search.IndexEntry + remote []search.IndexEntry + queryID uint64 + cancel context.CancelFunc + loading bool + err error +} + +var nextQueryID atomic.Uint64 + +type queryReadyMsg struct{ id uint64 } +type resultsMsg struct { + id uint64 + entries []search.IndexEntry + err error } func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { ti := textinput.New() - ti.Placeholder = "Search titles, actors, directors..." + ti.Placeholder = "Artists, albums, tracks, movies..." ti.Focus() ti.Prompt = " " @@ -52,12 +69,13 @@ func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { l.KeyMap.Quit.SetKeys("esc") idx := search.GetIndex() + entries, _ := idx.Snapshot() return &SearchOverlayModel{ textInput: ti, list: l, theme: theme, - allEntries: idx.Entries, + allEntries: entries, } } @@ -69,6 +87,26 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case queryReadyMsg: + if msg.id != m.queryID { + return m, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + m.cancel = cancel + query, id := m.textInput.Value(), m.queryID + return m, func() tea.Msg { + defer cancel() + entries, err := search.Live(ctx, query) + return resultsMsg{id: id, entries: entries, err: err} + } + case resultsMsg: + if msg.id == m.queryID { + m.loading = false + m.remote = msg.entries + m.err = msg.err + m.runSearch() + } + return m, nil case tea.MouseMsg: msg.X -= 3 msg.Y -= 4 @@ -84,6 +122,7 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc": + m.Close() return nil, nil case "up", "down": var lCmd tea.Cmd @@ -92,6 +131,7 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if m.list.SelectedItem() != nil { item := m.list.SelectedItem().(searchResultItem) + m.Close() return nil, func() tea.Msg { return ui.SelectMediaMsg{ RatingKey: item.entry.RatingKey, @@ -109,8 +149,17 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, tiCmd) if m.textInput.Value() != oldValue { - if m.textInput.Value() != "" { + m.Close() + m.queryID = nextQueryID.Add(1) + m.remote = nil + m.err = nil + m.loading = strings.TrimSpace(m.textInput.Value()) != "" + if m.loading { m.runSearch() + id := m.queryID + cmds = append(cmds, tea.Tick(250*time.Millisecond, func(time.Time) tea.Msg { + return queryReadyMsg{id: id} + })) } else { m.list.SetItems(nil) } @@ -127,10 +176,19 @@ func (m *SearchOverlayModel) runSearch() { query := m.textInput.Value() matches := fuzzy.FindFrom(query, entrySource(m.allEntries)) - var items []list.Item + items := []list.Item{} + seen := map[string]bool{} + for _, entry := range m.remote { + items = append(items, searchResultItem{entry: entry}) + seen[entry.RatingKey] = true + } for _, match := range matches { - items = append(items, searchResultItem{entry: m.allEntries[match.Index]}) - if len(items) >= 20 { // Limit results for performance + entry := m.allEntries[match.Index] + if !seen[entry.RatingKey] { + items = append(items, searchResultItem{entry: entry}) + seen[entry.RatingKey] = true + } + if len(items) >= 200 { break } } @@ -140,7 +198,7 @@ func (m *SearchOverlayModel) runSearch() { type entrySource []search.IndexEntry -func (s entrySource) String(i int) string { return s[i].Title } +func (s entrySource) String(i int) string { return s[i].SearchableText() } func (s entrySource) Len() int { return len(s) } func (m *SearchOverlayModel) View() string { @@ -151,11 +209,28 @@ func (m *SearchOverlayModel) View() string { Padding(1, 2). Background(lipgloss.Color("#111111")) + status := "Search your entire library · [enter] Open · [esc] Back" + switch { + case m.loading: + status = "Searching Plex…" + case m.err != nil: + status = "Server search unavailable; showing local matches. Retype to retry." + case m.textInput.Value() != "": + status = fmt.Sprintf("%d results · [enter] Open · Refine your search for more", len(m.list.Items())) + } content := lipgloss.JoinVertical(lipgloss.Left, m.textInput.View(), "", m.list.View(), + ui.Ellipsis(status, m.list.Width()), ) return overlayStyle.Render(content) } + +func (m *SearchOverlayModel) Close() { + if m.cancel != nil { + m.cancel() + m.cancel = nil + } +} diff --git a/internal/tui/widget/settings/settings.go b/internal/tui/widget/settings/settings.go index b7025cd..070536e 100644 --- a/internal/tui/widget/settings/settings.go +++ b/internal/tui/widget/settings/settings.go @@ -78,6 +78,8 @@ func (m *SettingsOverlayModel) updateItems() { settingItem{id: "auto_home_login", title: "Auto Home Login", description: "Login automatically if token exists", current: fmt.Sprintf("%v", cfg.AutoHomeLogin)}, settingItem{id: "close_video_on_quit", title: "Close Video On Quit", description: "Close mpv when exiting app", current: fmt.Sprintf("%v", cfg.CloseVideoOnQuit)}, settingItem{id: "terminal_video", title: "Terminal Video", description: "Video renderer for Ctrl+P", current: string(cfg.TerminalVideo)}, + settingItem{id: "music_player", title: "Music Player", description: "Where music plays", current: cfg.MusicPlayer}, + settingItem{id: "music_visualizer", title: "Music Visualizer", description: "Audio-reactive terminal display (z to cycle)", current: cfg.MusicVisualizer}, } m.list.SetItems(items) } @@ -231,6 +233,19 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { selectionItem{id: string(config.VideoSixel), value: "Sixel graphics"}, selectionItem{id: string(config.VideoTCT), value: "TCT (text fallback)"}, } + case "music_player": + m.selectionList.Title = "Choose Music Player" + items = []list.Item{ + selectionItem{id: "terminal", value: "In this terminal (audio + visualizer)"}, + selectionItem{id: "window", value: "Separate player window"}, + } + case "music_visualizer": + m.selectionList.Title = "Choose Music Visualizer" + items = []list.Item{ + selectionItem{id: "spectrum", value: "Spectrum · gradient bars and falling peaks"}, + selectionItem{id: "mirror", value: "Mirror · symmetric spectrum"}, + selectionItem{id: "off", value: "Off · compact controls"}, + } } m.selectionList.SetItems(items) @@ -248,6 +263,10 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { current = string(cfg.DefaultViewMode) case "terminal_video": current = string(cfg.TerminalVideo) + case "music_player": + current = cfg.MusicPlayer + case "music_visualizer": + current = cfg.MusicVisualizer } for i, it := range items { @@ -271,6 +290,10 @@ func (m *SettingsOverlayModel) applySetting(setting, value string) { cfg.DefaultViewMode = config.ViewMode(value) case "terminal_video": cfg.TerminalVideo = config.VideoOutput(value) + case "music_player": + cfg.MusicPlayer = value + case "music_visualizer": + cfg.MusicVisualizer = value } _ = cfg.Save() } diff --git a/internal/tui/widget/visualizer/visualizer.go b/internal/tui/widget/visualizer/visualizer.go new file mode 100644 index 0000000..7ae2146 --- /dev/null +++ b/internal/tui/widget/visualizer/visualizer.go @@ -0,0 +1,133 @@ +// Package visualizer renders audio levels with ordinary terminal cells. It +// requires neither terminal graphics extensions nor access to a sound device. +package visualizer + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/tui/player" + "github.com/ygelfand/plexctl/internal/ui" +) + +type Model struct { + levels [player.SpectrumBands]float64 + peaks [player.SpectrumBands]float64 + hold [player.SpectrumBands]float64 + left float64 + right float64 + valid bool +} + +func amplitude(db float64) float64 { + if math.IsNaN(db) || math.IsInf(db, 0) { + return 0 + } + return math.Pow(max(0, min(1, (db+65)/57)), 1.25) +} + +// Step uses quick attack, slower release, and independent peak hold. Silence +// and pause settle to zero; every movement is driven by measured audio levels. +func (m *Model) Step(frame player.Spectrum, paused bool, elapsed time.Duration) { + dt := max(0, min(0.25, elapsed.Seconds())) + active := frame.Valid && !paused + m.valid = m.valid || frame.Valid + for i := range m.levels { + target := 0.0 + if active { + target = amplitude(frame.Bands[i]) + } + tau := 0.16 + if target > m.levels[i] { + tau = 0.035 + } + m.levels[i] += (target - m.levels[i]) * (1 - math.Exp(-dt/tau)) + if m.levels[i] >= m.peaks[i] { + m.peaks[i], m.hold[i] = m.levels[i], 0.35 + } else if m.hold[i] > 0 { + m.hold[i] -= dt + } else { + m.peaks[i] = max(m.levels[i], m.peaks[i]-dt*0.42) + } + } + left, right := 0.0, 0.0 + if active { + left, right = amplitude(frame.Left), amplitude(frame.Right) + } + m.left += (left - m.left) * (1 - math.Exp(-dt/0.08)) + m.right += (right - m.right) * (1 - math.Exp(-dt/0.08)) +} + +var palette = [...]string{ + "#22d3a6", "#22d3bd", "#22d3d3", "#26c5ec", "#38b5ff", "#559cff", "#7585ff", "#9470ff", + "#b36bff", "#cf68f5", "#e868df", "#f768c1", "#fb769e", "#ff897d", "#ffa665", "#ffc857", +} + +// View returns height rows of spectrum plus one row of stereo meters. +func (m *Model) View(width, height int, mode string) string { + if width < 1 || height < 1 { + return "" + } + count := max(1, min(player.SpectrumBands, width/3)) + step := max(1, width/count) + barWidth := max(1, step-1) + rows := make([]string, height) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#334155")) + peak := lipgloss.NewStyle().Foreground(lipgloss.Color("#e5f4ff")) + for row := range height { + var line strings.Builder + for band := range count { + start, end := band*player.SpectrumBands/count, (band+1)*player.SpectrumBands/count + level, held := 0.0, 0.0 + for i := start; i < end; i++ { + level, held = max(level, m.levels[i]), max(held, m.peaks[i]) + } + units := level*float64(height) - float64(height-1-row) + if mode == "mirror" { + center := float64(height-1) / 2 + units = level*float64(height)/2 - math.Abs(float64(row)-center) + 0.5 + } + glyph := block(units) + style := lipgloss.NewStyle().Foreground(lipgloss.Color(palette[start])) + if mode == "spectrum" && held > 0.025 && row == height-1-int(held*float64(height-1)) && units < 0.9 { + glyph, style = "▔", peak + } + if glyph == " " && row == height-1 { + glyph, style = "·", muted + } + line.WriteString(style.Render(strings.Repeat(glyph, barWidth))) + line.WriteString(strings.Repeat(" ", step-barWidth)) + } + rows[row] = lipgloss.NewStyle().Width(width).MaxWidth(width).Render(line.String()) + } + label := "40 Hz · SPECTRUM · 16 kHz" + if mode == "mirror" { + label = "40 Hz · MIRROR · 16 kHz" + } + if !m.valid { + label = "Waiting for audio levels" + } + meterWidth := max(1, min(10, (width-lipgloss.Width(label)-9)/2)) + left := meter("L", m.left, meterWidth, palette[3]) + right := meter("R", m.right, meterWidth, palette[11]) + meters := left + " " + right + gap := max(1, width-lipgloss.Width(meters)-lipgloss.Width(label)) + legend := meters + strings.Repeat(" ", gap) + muted.Render(label) + rows = append(rows, ui.Ellipsis(legend, width)) + return strings.Join(rows, "\n") +} + +func block(value float64) string { + const blocks = " ▁▂▃▄▅▆▇█" + return string([]rune(blocks)[int(math.Round(max(0, min(1, value))*8))]) +} + +func meter(label string, value float64, width int, color string) string { + filled := int(math.Round(max(0, min(1, value)) * float64(width))) + return fmt.Sprintf("%s %s%s", label, + lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(strings.Repeat("━", filled)), + lipgloss.NewStyle().Foreground(lipgloss.Color("#334155")).Render(strings.Repeat("─", width-filled))) +} diff --git a/internal/tui/widget/visualizer/visualizer_test.go b/internal/tui/widget/visualizer/visualizer_test.go new file mode 100644 index 0000000..1b2c62d --- /dev/null +++ b/internal/tui/widget/visualizer/visualizer_test.go @@ -0,0 +1,52 @@ +package visualizer + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/tui/player" +) + +func TestVisualizerRespondsToAudioAndSettlesOnPause(t *testing.T) { + var m Model + frame := player.Spectrum{Valid: true, Left: -8, Right: -20} + for i := range frame.Bands { + frame.Bands[i] = -100 + } + frame.Bands[3] = -12 + for range 10 { + m.Step(frame, false, 50*time.Millisecond) + } + if m.levels[3] < 0.7 || m.levels[9] != 0 || m.left <= m.right { + t.Fatal("display did not follow the measured frequency and stereo levels") + } + for range 100 { + m.Step(frame, true, 50*time.Millisecond) + } + if m.levels[3] > 0.001 || m.peaks[3] > 0.001 { + t.Fatal("paused audio kept animating") + } +} + +func TestVisualizerFitsTerminalSizes(t *testing.T) { + for _, mode := range []string{"spectrum", "mirror"} { + t.Run(mode, func(t *testing.T) { + var m Model + for _, width := range []int{1, 12, 40, 53, 130} { + for _, height := range []int{1, 2, 6} { + view := m.View(width, height, mode) + if lipgloss.Height(view) != height+1 { + t.Fatalf("%dx%d: unexpected height %d", width, height, lipgloss.Height(view)) + } + for _, line := range strings.Split(view, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("%dx%d: line overflows terminal", width, height) + } + } + } + } + }) + } +} diff --git a/internal/ui/alphabet.go b/internal/ui/alphabet.go new file mode 100644 index 0000000..8f55308 --- /dev/null +++ b/internal/ui/alphabet.go @@ -0,0 +1,106 @@ +package ui + +import ( + "strings" + "unicode" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tint "github.com/lrstanley/bubbletint" + "golang.org/x/text/unicode/norm" +) + +const AlphabetLetters = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +type JumpToLetterMsg struct{ Letter string } + +type LetterJumper interface { + CanJumpLetters() bool + JumpToLetter(string) tea.Cmd +} + +// AlphabetRail folds into additional columns in short panes. The same geometry +// drives rendering and hit testing, including after the music player resizes it. +type AlphabetRail struct{ Rect } + +func NewAlphabetRail(right, top, height int) AlphabetRail { + if height < 5 || right < 12 { + return AlphabetRail{} + } + rows := min(len(AlphabetLetters), height) + width := ((len(AlphabetLetters) + rows - 1) / rows) * 3 + if width+8 > right { + return AlphabetRail{} + } + return AlphabetRail{Rect{X: right - width, Y: top, Width: width, Height: rows}} +} + +func (r AlphabetRail) LetterAt(mouse tea.MouseMsg) string { + local, inside := r.Local(mouse) + if !inside || !LeftClick(mouse) { + return "" + } + i := (local.X/3)*r.Height + local.Y + if i >= len(AlphabetLetters) { + return "" + } + return AlphabetLetters[i : i+1] +} + +func (r AlphabetRail) View(active string, theme tint.Tint) string { + if r.Width == 0 { + return "" + } + rows := make([]string, r.Height) + for y := range rows { + for x := range r.Width / 3 { + i := x*r.Height + y + cell := " " + if i < len(AlphabetLetters) { + letter := AlphabetLetters[i : i+1] + style := lipgloss.NewStyle().Foreground(theme.BrightBlack()) + if letter == active { + style = style.Foreground(theme.Black()).Background(Accent(theme)).Bold(true) + } + cell = style.Render(" " + letter + " ") + } + rows[y] += cell + } + } + return strings.Join(rows, "\n") +} + +// AlphabetTitle respects Plex's sort title (including its article handling). +// Latin accents share their base letter; numbers and other alphabets use #. +func AlphabetTitle(meta components.Metadata) string { + title := meta.Title + if meta.TitleSort != nil && *meta.TitleSort != "" { + title = *meta.TitleSort + } + return strings.Map(func(r rune) rune { + if unicode.Is(unicode.Mn, r) { + return -1 + } + return unicode.ToUpper(r) + }, norm.NFD.String(strings.TrimSpace(title))) +} + +func AlphabetLetter(meta components.Metadata) string { + for _, r := range AlphabetTitle(meta) { + if r >= 'A' && r <= 'Z' { + return string(r) + } + return "#" + } + return "#" +} + +func AlphabetIndex(items []components.Metadata, letter string) int { + for i, item := range items { + if AlphabetLetter(item) == letter { + return i + } + } + return -1 +} diff --git a/internal/ui/alphabet_test.go b/internal/ui/alphabet_test.go new file mode 100644 index 0000000..e919da3 --- /dev/null +++ b/internal/ui/alphabet_test.go @@ -0,0 +1,53 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +func TestAlphabetRailMouseMatchesRenderedLettersAfterResize(t *testing.T) { + for _, height := range []int{40, 27, 26, 14, 9, 6, 5} { + rail := NewAlphabetRail(50, 4, height) + seen := "" + for y, line := range strings.Split(ansi.Strip(rail.View("M", PlexctlTheme)), "\n") { + for x, letter := range line { + if letter == ' ' { + continue + } + mouse := tea.MouseMsg{X: rail.X + x, Y: rail.Y + y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + if got := rail.LetterAt(mouse); got != string(letter) { + t.Fatalf("height %d: clicked %c, got %q", height, letter, got) + } + mouse.Action = tea.MouseActionRelease + if rail.LetterAt(mouse) != "" { + t.Fatal("release activated a letter") + } + seen += string(letter) + } + } + if len(seen) != 27 { + t.Fatalf("height %d: only %d letters visible", height, len(seen)) + } + } +} + +func TestAlphabetRespectsSortTitleAccentsAndNumbers(t *testing.T) { + for _, test := range []struct { + meta components.Metadata + want string + }{ + {components.Metadata{Title: "The Matrix", TitleSort: Ptr("Matrix")}, "M"}, + {components.Metadata{Title: " Émilie"}, "E"}, + {components.Metadata{Title: "123"}, "#"}, + {components.Metadata{Title: "東京"}, "#"}, + {components.Metadata{}, "#"}, + } { + if got := AlphabetLetter(test.meta); got != test.want { + t.Fatalf("%q: got %q, want %q", test.meta.Title, got, test.want) + } + } +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 0c954e3..3ded940 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -40,9 +40,16 @@ type LayoutManager struct { totalWidth int totalHeight int playerActive bool + playerHeight int theme tint.Tint } +func (l *LayoutManager) SetPlayerHeight(height int) { + l.mu.Lock() + defer l.mu.Unlock() + l.playerHeight = height +} + var ( layoutInstance *LayoutManager layoutOnce sync.Once @@ -113,9 +120,9 @@ func (l *LayoutManager) ContentHeight() int { // -2 for main container horizontal borders, -1 for footer h := max(l.totalHeight-3, 0) if l.playerActive { - h -= 4 // Space for player bar + h -= max(4, l.playerHeight) } - return h + return max(0, h) } // PosterColumns calculates exactly how many full posters fit in the current inner width diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index 0345803..b403b08 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -49,6 +49,7 @@ func KeyPress(key string) tea.KeyMsg { types := map[string]tea.KeyType{ "enter": tea.KeyEnter, "esc": tea.KeyEsc, "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, "tab": tea.KeyTab, + "home": tea.KeyHome, "end": tea.KeyEnd, "pgup": tea.KeyPgUp, "pgdown": tea.KeyPgDown, "ctrl+s": tea.KeyCtrlS, "ctrl+l": tea.KeyCtrlL, "↑": tea.KeyUp, "↓": tea.KeyDown, "arrows": tea.KeyTab, } diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index e068548..50db70d 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -5,7 +5,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" - "github.com/mattn/go-runewidth" ) // Overlay composites the 'overlay' string on top of the 'base' string, @@ -56,12 +55,12 @@ func Overlay(base, overlay string, width, height int) string { // Ellipsis truncates a string to a max width and adds ... if needed. func Ellipsis(s string, maxWidth int) string { - w := runewidth.StringWidth(s) + w := ansi.StringWidth(s) if w <= maxWidth { return s } if maxWidth <= 3 { - return strings.Repeat(".", maxWidth) + return strings.Repeat(".", max(0, maxWidth)) } - return runewidth.Truncate(s, maxWidth-3, "...") + return ansi.Truncate(s, maxWidth, "...") } diff --git a/internal/ui/overlay_test.go b/internal/ui/overlay_test.go new file mode 100644 index 0000000..b5e466b --- /dev/null +++ b/internal/ui/overlay_test.go @@ -0,0 +1,20 @@ +package ui + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestEllipsisCountsVisibleCells(t *testing.T) { + styled := "\x1b[38;2;20;200;200mPLAYING\x1b[0m" + if got := Ellipsis(styled, 7); got != styled { + t.Fatalf("color escapes caused premature truncation: %q", got) + } + if got := Ellipsis(styled+" 01:23", 10); ansi.StringWidth(got) != 10 || ansi.Strip(got) != "PLAYING..." { + t.Fatalf("incorrect styled truncation: %q", got) + } + if Ellipsis(styled, -1) != "" { + t.Fatal("negative width should be empty") + } +} diff --git a/internal/ui/types.go b/internal/ui/types.go index 416613f..38637c1 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -50,8 +50,12 @@ type SwitchUserMsg struct { type InvalidPinMsg struct{} type MediaPageMsg struct { - Metadata []components.Metadata - Total int + SectionID string + RequestID uint64 + Start int + Metadata []components.Metadata + Total int + Err error } type RootChecker interface { From e9fe7c137593b30b5bca535263b26a174ef1d80f Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:02:28 -0400 Subject: [PATCH 03/21] feat: add visible Kitty video playback controls --- README.md | 5 + docs/content/tui.mdx | 16 +- internal/termgraphics/terminal_test.go | 23 ++ internal/termgraphics/writer.go | 68 ++++- internal/tui/player/terminal.go | 114 +++++++- internal/tui/player/terminal_controls.go | 262 ++++++++++++++++++ internal/tui/player/terminal_controls_test.go | 99 +++++++ .../tui/player/terminal_integration_test.go | 45 +++ internal/tui/player/terminal_keys.go | 85 ++++-- internal/tui/player/terminal_keys_test.go | 41 +++ internal/tui/player/terminal_unix.go | 10 + internal/tui/player/terminal_windows.go | 4 + internal/tui/widget/playback/playback.go | 1 + 13 files changed, 734 insertions(+), 39 deletions(-) create mode 100644 internal/tui/player/terminal_controls.go create mode 100644 internal/tui/player/terminal_controls_test.go diff --git a/README.md b/README.md index e563597..dc0d578 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ Simply run `plexctl` to launch the interactive terminal interface. Press **v** on a movie or episode to choose **Kitty**, **Sixel**, or **Text** playback. **Ctrl+P** starts terminal playback using your saved preference. +Kitty video includes a visible control bar: play/pause, a draggable seek bar, +skip backward/forward, volume, mute, subtitles, playback speed, and **Return**. +Click the controls or use **Tab / Shift+Tab** and **Enter**; **h** hides or shows +the bar. The controls also work through tmux and SSH into a Kitty terminal. + The player uses mpv's Kitty or Sixel graphics output when detected, with TCT text output as a fallback. Press **Space** to pause, use the arrow keys to seek, and press **q** to return to the library. Regular **p** opens the video window. diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 370977d..7ee9957 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -130,11 +130,23 @@ Press **`v`** on a movie or episode to open the terminal playback menu. Select **Kitty graphics**, **Sixel graphics**, **Text**, or **Automatic**. The menu shows the detected terminal capabilities. **`ctrl+p`** uses the preference saved in Settings. Playback takes over the full terminal; the library view returns when -the video ends or you press **`q`**. During playback, mpv's keyboard bindings apply: +the video ends or you press **`q`**. -- **`Space`** or **left click**: Pause or resume. +Kitty video has a control bar drawn into the image, with elapsed/total time, +play/pause, **-10s / +10s**, a clickable and draggable timeline, volume, +mute, subtitle visibility (**CC**), playback speed, and **Return**. The buttons +and timeline follow the video when you resize the terminal, including through +tmux and SSH. **Tab / Shift+Tab** select a button and **Enter** activates it. +Press **`h`** to hide or show the controls. In Kitty, **Esc** also returns to the +library and **+ / -** adjust volume. Scrolling over the volume controls adjusts +volume; scrolling elsewhere seeks. + +During playback, these shortcuts are available in every terminal renderer: + +- **`Space`** or **left click on the picture**: Pause or resume. - **`Left` / `Right`** or **wheel**: Seek backward or forward. - **`9` / `0`**: Decrease or increase volume. +- **`m`**: Mute or unmute. - **`j`**: Cycle subtitle tracks. - **`q`** or **right click**: Stop and return to the library. diff --git a/internal/termgraphics/terminal_test.go b/internal/termgraphics/terminal_test.go index 6f7935a..a9445e9 100644 --- a/internal/termgraphics/terminal_test.go +++ b/internal/termgraphics/terminal_test.go @@ -81,6 +81,29 @@ func TestKittyPlaybackImageOwnership(t *testing.T) { } } +func TestKittyFramePlacement(t *testing.T) { + input := "\x1b[4;12H\x1b_Ga=T,f=24,s=800,v=450,C=1,q=2,m=1;AAAA\x1b\\\x1b_Gm=0;BBBB\x1b\\" + for split := 1; split <= len(input); split++ { + var output bytes.Buffer + w := Writer{Output: &output, Direct: true} + for at := 0; at < len(input); at += split { + if _, err := w.Write([]byte(input[at:min(at+split, len(input))])); err != nil { + t.Fatal(err) + } + } + if w.Frame() != (Frame{X: 11, Y: 3, Width: 800, Height: 450}) { + t.Fatalf("split %d: incorrect frame %+v", split, w.Frame()) + } + if output.String() != input { + t.Fatalf("split %d: direct graphics were altered", split) + } + w.Write([]byte("\x1b[0;0H\x1b_Ga=T,f=24,s=400,v=225;AAAA\x1b\\")) + if w.Frame() != (Frame{Width: 400, Height: 225}) { + t.Fatal("frame was not updated after resize") + } + } +} + func TestNativePostersRequireUnicodePlaceholders(t *testing.T) { for _, tt := range []struct { name string diff --git a/internal/termgraphics/writer.go b/internal/termgraphics/writer.go index 5d4515d..fa830a7 100644 --- a/internal/termgraphics/writer.go +++ b/internal/termgraphics/writer.go @@ -4,7 +4,9 @@ import ( "bytes" "fmt" "io" + "strconv" "strings" + "sync" "github.com/charmbracelet/x/ansi" ) @@ -14,9 +16,27 @@ import ( // Packets can span arbitrary writes from os/exec's stdout pipe. type Writer struct { Output io.Writer + // Direct observes graphics without wrapping them for tmux. + Direct bool // ImageID assigns frames to this playback session for targeted cleanup. - ImageID int - pending []byte + ImageID int + pending []byte + cursorX, cursorY int + mu sync.RWMutex + frame Frame +} + +// Frame describes the actual Kitty placement: a cell origin and pixel size. +// Read it while playback is running to map terminal mouse reports to the image, +// including letterboxing, custom video geometry, and terminal resizing. +type Frame struct { + X, Y, Width, Height int +} + +func (w *Writer) Frame() Frame { + w.mu.RLock() + defer w.mu.RUnlock() + return w.frame } func (w *Writer) Write(p []byte) (int, error) { @@ -36,6 +56,29 @@ func (w *Writer) Write(p []byte) (int, error) { if len(w.pending) < 2 { break } + if w.pending[1] == '[' { + end := 2 + for end < len(w.pending) && (w.pending[end] < 0x40 || w.pending[end] > 0x7e) { + end++ + } + if end == len(w.pending) { + if end > 4096 { + return 0, fmt.Errorf("terminal cursor sequence exceeds 4 KiB") + } + break + } + if w.pending[end] == 'H' || w.pending[end] == 'f' { + row, col, _ := strings.Cut(string(w.pending[2:end]), ";") + y, _ := strconv.Atoi(row) + x, _ := strconv.Atoi(col) + w.cursorX, w.cursorY = max(0, x-1), max(0, y-1) + } + if _, err := w.Output.Write(w.pending[:end+1]); err != nil { + return 0, err + } + w.pending = w.pending[end+1:] + continue + } if w.pending[1] != '_' && w.pending[1] != 'P' { if _, err := w.Output.Write(w.pending[:2]); err != nil { return 0, err @@ -52,6 +95,22 @@ func (w *Writer) Write(p []byte) (int, error) { } end += 4 sequence := string(w.pending[:end]) + if strings.HasPrefix(sequence, "\x1b_Ga=T,") { + head, _, _ := strings.Cut(sequence[3:], ";") + frame := Frame{X: w.cursorX, Y: w.cursorY} + for field := range strings.SplitSeq(head, ",") { + key, value, _ := strings.Cut(field, "=") + switch key { + case "s": + frame.Width, _ = strconv.Atoi(value) + case "v": + frame.Height, _ = strconv.Atoi(value) + } + } + w.mu.Lock() + w.frame = frame + w.mu.Unlock() + } if w.ImageID != 0 && strings.HasPrefix(sequence, "\x1b_G") { if head, payload, ok := strings.Cut(sequence, ";"); ok { switch { @@ -62,7 +121,10 @@ func (w *Writer) Write(p []byte) (int, error) { } } } - packet := ansi.TmuxPassthrough(sequence) + packet := sequence + if !w.Direct { + packet = ansi.TmuxPassthrough(sequence) + } if _, err := io.WriteString(w.Output, packet); err != nil { return 0, err } diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index 448a7c9..573c1ba 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -97,6 +97,9 @@ func (p *terminalPlayback) args(socket string) []string { if p.output == config.VideoKitty { // Escape-code transport also works over SSH; shared memory does not. args = append(args, "--vo-kitty-use-shm=no") + // Our control bar uses mpv's OSD compositor and IPC, independently + // of the Lua OSC and its window-system mouse events. + args = append(args, "--osc=no") } return args } @@ -137,6 +140,9 @@ func (p *terminalPlayback) Run() error { cmd := exec.Command(p.mpv, p.args(socket)...) cmd.Stdin, cmd.Stdout, cmd.Stderr = p.stdin, p.stdout, io.Discard var keys <-chan string + var graphics *termgraphics.Writer + var controls *terminalControls + var cellW, cellH float64 if termgraphics.InTmux() && (p.output == config.VideoKitty || p.output == config.VideoSixel) { restore, err := termgraphics.EnablePassthrough() if err != nil { @@ -148,15 +154,19 @@ func (p *terminalPlayback) Run() error { return err } defer unzoom() - imageID := (1 << 30) + rand.IntN(1<<20) - cmd.Stdout = &termgraphics.Writer{Output: p.stdout, ImageID: imageID} - defer func() { - if p.output == config.VideoKitty { - _, _ = io.WriteString(p.stdout, termgraphics.Wrap(termgraphics.DeleteImage(imageID))) - } else { + if p.output == config.VideoSixel { + cmd.Stdout = &termgraphics.Writer{Output: p.stdout} + defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap("\x1b[2J\x1b[H")) - } - }() + }() + } + } + if p.output == config.VideoKitty { + controls = &terminalControls{focus: -1, hover: -1, speed: 1, subtitles: true} + p.status.Volume = 100 + graphics = &termgraphics.Writer{Output: p.stdout, Direct: !termgraphics.InTmux(), ImageID: (1 << 30) + rand.IntN(1<<20)} + cmd.Stdout = graphics + defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap(termgraphics.DeleteImage(graphics.ImageID))) }() } // Our wrapper handles actual tmux panes. Also remove stale tmux variables // from direct Kitty windows so mpv cannot wrap their graphics by mistake. @@ -177,6 +187,13 @@ func (p *terminalPlayback) Run() error { defer stop() _, _ = io.WriteString(p.stdout, "\x1b[?1000h\x1b[?1006h") defer func() { _, _ = io.WriteString(p.stdout, "\x1b[?1000l\x1b[?1006l") }() + if controls != nil { + cellW, cellH = terminalCellSize(input) + // Motion supports seek dragging and button highlights. Ask the + // actual terminal for cell metrics even over SSH. + _, _ = io.WriteString(p.stdout, "\x1b[?1003h"+termgraphics.Wrap("\x1b[16t")) + defer func() { _, _ = io.WriteString(p.stdout, "\x1b[?1003l") }() + } } if err := cmd.Start(); err != nil { return fmt.Errorf("start terminal player: %w", err) @@ -214,7 +231,11 @@ func (p *terminalPlayback) Run() error { <-readerDone }() - for id, property := range []string{"time-pos", "pause", "duration"} { + properties := []string{"time-pos", "pause", "duration"} + if controls != nil { + properties = append(properties, "volume", "mute", "speed", "sub-visibility") + } + for id, property := range properties { if err := sendTerminalCommand(conn, 0, "observe_property", id+1, property); err != nil { return err } @@ -233,15 +254,45 @@ func (p *terminalPlayback) Run() error { }() ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() + controlTicker := time.NewTicker(100 * time.Millisecond) + defer controlTicker.Stop() + var controlColumns, controlRows int keyTimer := time.NewTimer(time.Hour) keyTimer.Stop() defer keyTimer.Stop() pendingKeys := "" forwardKeys := func(flush bool) error { - var decoded []string - decoded, pendingKeys = playbackKeys(pendingKeys, flush) - for _, key := range decoded { - if err := sendTerminalCommand(conn, 0, "keypress", key); err != nil { + var decoded []playbackInput + decoded, pendingKeys = playbackInputs(pendingKeys, flush) + for _, input := range decoded { + if input.CellWidth > 0 { + cellW, cellH = input.CellWidth, input.CellHeight + continue + } + var command []any + if controls != nil { + // ioctl follows window and font changes when pixel metrics are + // available; otherwise retain the terminal's cell-size reply. + if f, ok := p.stdin.(*os.File); ok { + if w, h := terminalCellSize(f); w > 0 { + cellW, cellH = w, h + } + } + frame := graphics.Frame() + command = controls.input(input, controls.layout(frame, p.status), frame, cellW, cellH, p.status) + } else { + key := input.Key + if input.Mouse != nil { + key = input.Mouse.key() + } + if key != "" { + command = []any{"keypress", key} + } + } + if len(command) == 0 { + continue + } + if err := sendTerminalCommand(conn, 0, command...); err != nil { return err } } @@ -266,6 +317,28 @@ func (p *terminalPlayback) Run() error { if err := forwardKeys(true); err != nil { return err } + case <-controlTicker.C: + if controls == nil || !loaded { + continue + } + layout := controls.layout(graphics.Frame(), p.status) + resized := false + if input, ok := p.stdin.(*os.File); ok { + if columns, rows, err := term.GetSize(int(input.Fd())); err == nil { + resized = columns != controlColumns || rows != controlRows + controlColumns, controlRows = columns, rows + } + } + overlay := controls.overlay(layout, p.status) + // A paused Kitty VO needs an OSD redraw to process SIGWINCH. + // Resending the overlay preserves pause and the playback position. + if overlay == controls.lastOverlay && !resized { + continue + } + if err := sendTerminalCommand(conn, 2, "osd-overlay", 1, "ass-events", overlay, layout.w, layout.h); err != nil { + return err + } + controls.lastOverlay = overlay case ev, ok := <-events: if !ok { // IPC normally closes just before Wait returns on EOF or q. @@ -280,6 +353,9 @@ func (p *terminalPlayback) Run() error { if ev.RequestID == 1 && ev.Error != "success" { return fmt.Errorf("mpv rejected the media stream") } + if ev.RequestID == 2 && ev.Error != "success" { + return fmt.Errorf("mpv could not draw video controls; use an mpv build with libass OSD support") + } switch ev.Event { case "file-loaded": loaded = true @@ -293,6 +369,14 @@ func (p *terminalPlayback) Run() error { case "property-change": oldState := p.status.State p.applyProperty(ev) + if controls != nil && len(ev.Data) > 0 && string(ev.Data) != "null" { + switch ev.ID { + case 6: + _ = json.Unmarshal(ev.Data, &controls.speed) + case 7: + _ = json.Unmarshal(ev.Data, &controls.subtitles) + } + } if loaded && p.status.State != oldState { p.reportStatus() } @@ -362,6 +446,10 @@ func (p *terminalPlayback) applyProperty(ev terminalEvent) { } case 3: _ = json.Unmarshal(ev.Data, &p.status.Duration) + case 4: + _ = json.Unmarshal(ev.Data, &p.status.Volume) + case 5: + _ = json.Unmarshal(ev.Data, &p.status.Muted) } } diff --git a/internal/tui/player/terminal_controls.go b/internal/tui/player/terminal_controls.go new file mode 100644 index 0000000..a04ce03 --- /dev/null +++ b/internal/tui/player/terminal_controls.go @@ -0,0 +1,262 @@ +package player + +import ( + "fmt" + "math" + "strings" + "unicode" + + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +// The controls are composited by mpv into the Kitty image. Drawing terminal +// text over that image would be erased on every video frame. A single layout +// supplies both the ASS overlay and the mouse hit targets. +type terminalControls struct { + hidden bool + focus int + hover int + dragging bool + speed float64 + subtitles bool + lastOverlay string +} + +type videoRect struct{ x, y, w, h float64 } + +func (r videoRect) contains(x, y float64) bool { + return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h +} + +type videoButton struct { + videoRect + label string + command []any +} + +type videoControlsLayout struct { + w, h int + bar, seek videoRect + buttons []videoButton +} + +func (c *terminalControls) layout(frame termgraphics.Frame, status PlayerStatus) videoControlsLayout { + if frame.Width <= 0 || frame.Height <= 0 { + return videoControlsLayout{} + } + w := max(720, min(1080, frame.Width)) + h := max(120, int(float64(w)*float64(frame.Height)/float64(frame.Width))) + l := videoControlsLayout{w: w, h: h, bar: videoRect{0, float64(h) - 112, float64(w), 112}, + seek: videoRect{18, float64(h) - 71, float64(w) - 36, 20}} + pause := "Pause" + if status.Paused() { + pause = "Play" + } + volume := fmt.Sprintf("Vol %.0f%%", status.Volume) + if status.Muted { + volume = "Muted" + } + subs := "CC off" + if c.subtitles { + subs = "CC on" + } + add := func(x, width float64, label string, command ...any) { + l.buttons = append(l.buttons, videoButton{videoRect{x, float64(h) - 43, width, 32}, label, command}) + } + add(18, 80, pause, "cycle", "pause") + add(106, 58, "-10s", "seek", -10, "relative+exact") + add(172, 58, "+10s", "seek", 10, "relative+exact") + add(238, 100, volume, "cycle", "mute") + add(346, 36, "-", "add", "volume", -5) + add(390, 36, "+", "add", "volume", 5) + add(434, 72, subs, "cycle", "sub-visibility") + nextSpeed := c.speed + .25 + if nextSpeed > 2 { + nextSpeed = .5 + } + add(514, 62, fmt.Sprintf("%gx", c.speed), "set_property", "speed", nextSpeed) + add(float64(w)-126, 108, "Return", "quit") + return l +} + +func videoTime(seconds float64) string { + if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds < 0 { + seconds = 0 + } + s := int(seconds) + if s >= 3600 { + return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) + } + return fmt.Sprintf("%d:%02d", s/60, s%60) +} + +// Media titles are untrusted display text, never ASS tags or escape sequences. +func videoLabel(text string, limit int) string { + runes := []rune(strings.Map(func(r rune) rune { + switch r { + case '{': + return '{' + case '}': + return '}' + case '\\': + return '\' + } + if unicode.IsControl(r) { + return ' ' + } + return r + }, text)) + if len(runes) > limit { + return string(runes[:max(0, limit-1)]) + "…" + } + return string(runes) +} + +func (c *terminalControls) overlay(l videoControlsLayout, status PlayerStatus) string { + if c.hidden || l.w == 0 { + return "" + } + var b strings.Builder + rect := func(r videoRect, color string, alpha int) { + fmt.Fprintf(&b, "{\\an7\\pos(0,0)\\bord0\\shad0\\1c&H%s&\\1a&H%02X&\\p1}m %.1f %.1f l %.1f %.1f %.1f %.1f %.1f %.1f{\\p0}\n", + color, alpha, r.x, r.y, r.x+r.w, r.y, r.x+r.w, r.y+r.h, r.x, r.y+r.h) + } + text := func(x, y float64, size, anchor int, color, label string) { + fmt.Fprintf(&b, "{\\an%d\\pos(%.1f,%.1f)\\fnSans\\fs%d\\bord0\\shad0\\b0\\q2\\1a&H00&\\1c&H%s&}%s\n", anchor, x, y, size, color, label) + } + rect(l.bar, "211B15", 24) + title := videoLabel(status.Title, (l.w-420)/11) + " [h: hide]" + if c.focus >= 0 { + title = "Tab: next control Enter: activate Esc: return" + } + text(18, l.bar.y+9, 19, 7, "F4F1ED", title) + text(float64(l.w)-18, l.bar.y+9, 19, 9, "D3B94D", videoTime(status.Time)+" / "+videoTime(status.Duration)) + track := videoRect{l.seek.x, l.seek.y + 7, l.seek.w, 6} + rect(track, "645951", 0) + if status.Duration > 0 { + fraction := max(0, min(1, status.Time/status.Duration)) + fill := track + fill.w *= fraction + rect(fill, "E1CA58", 0) + rect(videoRect{track.x + fill.w - 3, track.y - 4, 6, 14}, "F4F1ED", 0) + } + for i, button := range l.buttons { + color := "46392D" + if i == 0 { + color = "856725" + } + if i == c.hover || i == c.focus { + color = "B39539" + } + rect(button.videoRect, color, 0) + text(button.x+button.w/2, button.y+button.h/2, 18, 5, "FFFFFF", button.label) + } + return b.String() +} + +// position uses the actual graphics placement, not the movie's source size or +// an assumed full-terminal image. SGR mouse coordinates identify cell centers. +func videoMousePosition(m playbackMouse, frame termgraphics.Frame, cellW, cellH float64, l videoControlsLayout) (float64, float64) { + if frame.Width <= 0 || frame.Height <= 0 || cellW <= 0 || cellH <= 0 { + return -1, -1 + } + x := (float64(m.X-frame.X) + .5) * cellW * float64(l.w) / float64(frame.Width) + y := (float64(m.Y-frame.Y) + .5) * cellH * float64(l.h) / float64(frame.Height) + return x, y +} + +func (c *terminalControls) input(input playbackInput, l videoControlsLayout, frame termgraphics.Frame, cellW, cellH float64, status PlayerStatus) []any { + if input.Mouse == nil { + switch input.Key { + case "h": + c.hidden, c.dragging, c.focus = !c.hidden, false, -1 + return nil + case "TAB", "Shift+TAB": + c.hidden = false + if len(l.buttons) > 0 { + delta := 1 + if input.Key == "Shift+TAB" { + delta = -1 + if c.focus < 0 { + c.focus = 0 + } + } + c.focus = (c.focus + delta + len(l.buttons)) % len(l.buttons) + } + return nil + case "ENTER": + if !c.hidden && c.focus >= 0 && c.focus < len(l.buttons) { + return l.buttons[c.focus].command + } + return []any{"cycle", "pause"} + case "ESC": + return []any{"quit"} + case "+", "=": + return []any{"add", "volume", 5} + case "-": + return []any{"add", "volume", -5} + } + return []any{"keypress", input.Key} + } + m := *input.Mouse + x, y := videoMousePosition(m, frame, cellW, cellH, l) + motion, wheel := m.Button&32 != 0, m.Button&64 != 0 + if m.Pressed && !motion && !wheel && m.Button&3 == 2 { + return []any{"quit"} + } + c.hover = -1 + if !c.hidden { + for i, b := range l.buttons { + if b.contains(x, y) { + c.hover = i + break + } + } + } + if wheel && m.Pressed { + delta := 5 + if m.Button&3 == 1 || m.Button&3 == 2 { + delta = -5 + } + if c.hover >= 3 && c.hover <= 5 { + return []any{"add", "volume", delta} + } + return []any{"seek", delta * 2, "relative+exact"} + } + seek := func() []any { + if status.Duration <= 0 || l.seek.w <= 0 { + return nil + } + seconds := max(0, min(1, (x-l.seek.x)/l.seek.w)) * status.Duration + return []any{"seek", seconds, "absolute+exact"} + } + if !m.Pressed { + wasDragging := c.dragging + c.dragging = false + if wasDragging { + return seek() + } + return nil + } + if motion { + if c.dragging && m.Button&3 == 0 { + return seek() + } + return nil + } + if m.Button&3 != 0 { + return nil + } + c.focus = -1 + if !c.hidden && l.seek.contains(x, y) { + c.dragging = status.Duration > 0 + return seek() + } + if c.hover >= 0 { + return l.buttons[c.hover].command + } + if !c.hidden && l.bar.contains(x, y) { + return nil + } + return []any{"cycle", "pause"} +} diff --git a/internal/tui/player/terminal_controls_test.go b/internal/tui/player/terminal_controls_test.go new file mode 100644 index 0000000..1b7b26a --- /dev/null +++ b/internal/tui/player/terminal_controls_test.go @@ -0,0 +1,99 @@ +package player + +import ( + "math" + "reflect" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +func TestVideoControlsMouse(t *testing.T) { + for _, frame := range []termgraphics.Frame{ + {Width: 960, Height: 540}, + {X: 12, Y: 6, Width: 720, Height: 406}, + {X: 35, Y: 1, Width: 450, Height: 800}, + } { + c := terminalControls{focus: -1, hover: -1, speed: 1} + status := PlayerStatus{Time: 30, Duration: 120, Volume: 80} + l := c.layout(frame, status) + at := func(x, y float64, button int, pressed bool) []any { + m := playbackMouse{Button: button, Pressed: pressed, + X: frame.X + int(x*float64(frame.Width)/float64(l.w)/9), + Y: frame.Y + int(y*float64(frame.Height)/float64(l.h)/18)} + return c.input(playbackInput{Mouse: &m}, l, frame, 9, 18, status) + } + for i, want := range [][]any{ + {"cycle", "pause"}, {"seek", -10, "relative+exact"}, {"seek", 10, "relative+exact"}, + {"cycle", "mute"}, {"add", "volume", -5}, {"add", "volume", 5}, + {"cycle", "sub-visibility"}, {"set_property", "speed", 1.25}, {"quit"}, + } { + button := l.buttons[i] + got := at(button.x+button.w/2, button.y+button.h/2, 0, true) + if !reflect.DeepEqual(got, want) { + t.Fatalf("frame %+v button %s: got %v want %v", frame, button.label, got, want) + } + if got := at(button.x+button.w/2, button.y+button.h/2, 0, false); len(got) != 0 { + t.Fatal("release activated a button twice", got) + } + } + for i, fraction := range []float64{.25, .6, .75} { + button, pressed := 0, true + if i == 1 { + button = 32 + } + if i == 2 { + pressed = false + } + got := at(l.seek.x+l.seek.w*fraction, l.seek.y+l.seek.h/2, button, pressed) + if len(got) != 3 || got[0] != "seek" || got[2] != "absolute+exact" || math.Abs(got[1].(float64)-fraction*120) > 3 { + t.Fatalf("click/drag/release seek fraction %v, frame %+v: %v", fraction, frame, got) + } + } + if c.dragging { + t.Fatal("mouse release left seek dragging active") + } + status.Duration = 0 + if got := at(l.seek.x+l.seek.w/2, l.seek.y+l.seek.h/2, 0, true); len(got) != 0 { + t.Fatal("seek without a duration", got) + } + } +} + +func TestVideoControlsKeyboardAndDisplay(t *testing.T) { + c := terminalControls{focus: -1, hover: -1, speed: 1} + frame := termgraphics.Frame{Width: 1280, Height: 720} + status := PlayerStatus{Title: "Film {\\pos(0,0)}\nsecret", Duration: 7200, Time: 3661, Volume: 75, State: operations.StatePaused} + l := c.layout(frame, status) + key := func(s string) []any { return c.input(playbackInput{Key: s}, l, frame, 8, 16, status) } + overlay := c.overlay(l, status) + for _, text := range []string{"Play", "Return", "Vol 75%", "1:01:01 / 2:00:00", "h: hide"} { + if !strings.Contains(overlay, text) { + t.Errorf("missing control/state %q", text) + } + } + if strings.Contains(overlay, "Film {\\pos") || strings.Contains(overlay, "\nsecret") { + t.Fatal("media title injected ASS markup") + } + key("TAB") + if got := key("ENTER"); !reflect.DeepEqual(got, []any{"cycle", "pause"}) { + t.Fatal("keyboard pause", got) + } + key("Shift+TAB") + if got := key("ENTER"); !reflect.DeepEqual(got, []any{"quit"}) { + t.Fatal("keyboard return", got) + } + key("h") + if c.overlay(l, status) != "" { + t.Fatal("hidden overlay still visible") + } + key("TAB") + if c.hidden { + t.Fatal("Tab did not reveal controls") + } + if got := key("RIGHT"); !reflect.DeepEqual(got, []any{"keypress", "RIGHT"}) { + t.Fatal("mpv arrow binding was lost") + } +} diff --git a/internal/tui/player/terminal_integration_test.go b/internal/tui/player/terminal_integration_test.go index b48ff27..a7b147e 100644 --- a/internal/tui/player/terminal_integration_test.go +++ b/internal/tui/player/terminal_integration_test.go @@ -56,6 +56,8 @@ func TestTerminalPlayback(t *testing.T) { {name: "startup-failure", wantErr: true}, {name: "load-failure", wantErr: true}, {name: "stream-failure", wantErr: true}, + {name: "controls", wantTime: 120}, + {name: "controls-failure", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -233,6 +235,49 @@ func TestTerminalMPVHelper(t *testing.T) { }{{3, 120}, {1, 31.5}, {2, true}, {2, false}} { _ = encoder.Encode(map[string]any{"event": "property-change", "id": property.id, "data": property.data}) } + if strings.HasPrefix(scenario, "controls") { + for id, data := range map[int]any{4: 65, 5: true, 6: 1.25, 7: false} { + _ = encoder.Encode(map[string]any{"event": "property-change", "id": id, "data": data}) + } + // The geometry is consumed by the real output adapter, without an + // emulator. Wait for a live overlay on the same playback IPC connection. + fmt.Print("\x1b[3;1H\x1b_Ga=T,f=24,s=960,v=540;AAAA\x1b\\") + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var request struct { + Command []any `json:"command"` + RequestID int `json:"request_id"` + } + if decoder.Decode(&request) != nil { + os.Exit(16) + } + if request.Command[0] != "osd-overlay" { + continue + } + if len(request.Command) != 6 || request.Command[2] != "ass-events" { + os.Exit(17) + } + overlay, _ := request.Command[3].(string) + for _, label := range []string{"Return", "Muted", "CC off", "1.25x", "0:31 / 2:00"} { + if !strings.Contains(overlay, label) { + os.Exit(18) + } + } + if strings.Contains(overlay, "secret") { + os.Exit(19) + } + err := "success" + if scenario == "controls-failure" { + err = "unsupported" + } + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": err}) + if scenario == "controls-failure" { + _, _ = io.Copy(io.Discard, conn) + return + } + break + } + } if scenario == "tui" { fmt.Println("video-start") time.Sleep(100 * time.Millisecond) diff --git a/internal/tui/player/terminal_keys.go b/internal/tui/player/terminal_keys.go index 9e05e86..1708715 100644 --- a/internal/tui/player/terminal_keys.go +++ b/internal/tui/player/terminal_keys.go @@ -60,31 +60,44 @@ var escapeKeys = map[string]string{ "\x1bOA": "UP", "\x1bOB": "DOWN", "\x1bOC": "RIGHT", "\x1bOD": "LEFT", "\x1b[H": "HOME", "\x1b[F": "END", "\x1b[5~": "PGUP", "\x1b[6~": "PGDWN", "\x1b[3~": "DEL", "\x1b[2~": "INS", + "\x1b[Z": "Shift+TAB", +} + +type playbackInput struct { + Key string + Mouse *playbackMouse + CellWidth, CellHeight float64 +} + +type playbackMouse struct { + Button, X, Y int // SGR button flags and zero-based cell coordinates. + Pressed bool } // playbackKeys preserves incomplete escape/UTF-8 sequences between pipe reads. -func playbackKeys(input string, flush bool) (keys []string, pending string) { +func playbackInputs(input string, flush bool) (inputs []playbackInput, pending string) { + key := func(s string) { inputs = append(inputs, playbackInput{Key: s}) } for input != "" { if strings.HasPrefix(input, "\x1b[<") { end := strings.IndexAny(input[3:], "Mm") if end < 0 { if !flush && len(input) <= 64 { - return keys, input + return inputs, input } - return keys, "" // Drop malformed reports without sending their digits to mpv. + return inputs, "" // Drop malformed reports without sending their digits to mpv. } end += 3 - if key := playbackMouseKey(input[3:end], input[end] == 'M'); key != "" { - keys = append(keys, key) + if mouse := parsePlaybackMouse(input[3:end], input[end] == 'M'); mouse != nil { + inputs = append(inputs, playbackInput{Mouse: mouse}) } input = input[end+1:] continue } matched, partial := false, false if input[0] == '\x1b' { - for sequence, key := range escapeKeys { + for sequence, name := range escapeKeys { if strings.HasPrefix(input, sequence) { - keys = append(keys, key) + key(name) input = input[len(sequence):] matched = true break @@ -95,47 +108,77 @@ func playbackKeys(input string, flush bool) (keys []string, pending string) { continue } if partial && !flush { - return keys, input + return inputs, input + } + // Ignore other CSI reports (including pixel/cursor replies), rather + // than treating their numbers as mpv volume or picture shortcuts. + if strings.HasPrefix(input, "\x1b[") { + end := 2 + for end < len(input) && (input[end] < 0x40 || input[end] > 0x7e) { + end++ + } + if end == len(input) { + if !flush && len(input) < 64 { + return inputs, input + } + return inputs, "" + } + if input[end] == 't' { + var h, w int + if n, _ := fmt.Sscanf(input[2:end], "6;%d;%d", &h, &w); n == 2 && h > 0 && w > 0 && h <= 1024 && w <= 1024 { + inputs = append(inputs, playbackInput{CellWidth: float64(w), CellHeight: float64(h)}) + } + } + input = input[end+1:] + continue } } if !utf8.FullRuneInString(input) && !flush { - return keys, input + return inputs, input } r, size := utf8.DecodeRuneInString(input) input = input[size:] switch r { case ' ': - keys = append(keys, "SPACE") + key("SPACE") case '\r', '\n': - keys = append(keys, "ENTER") + key("ENTER") case '\t': - keys = append(keys, "TAB") + key("TAB") case '\x1b': - keys = append(keys, "ESC") + key("ESC") case '\x03': - keys = append(keys, "q") + key("q") case '\x7f', '\b': - keys = append(keys, "BS") + key("BS") default: if r >= 32 && r != utf8.RuneError { - keys = append(keys, string(r)) + key(string(r)) } } } - return keys, "" + return inputs, "" } -func playbackMouseKey(report string, pressed bool) string { +func parsePlaybackMouse(report string, pressed bool) *playbackMouse { fields := strings.Split(report, ";") - if !pressed || len(fields) != 3 { - return "" + if len(fields) != 3 { + return nil } button, err := strconv.Atoi(fields[0]) x, xErr := strconv.Atoi(fields[1]) y, yErr := strconv.Atoi(fields[2]) - if err != nil || xErr != nil || yErr != nil || x <= 0 || y <= 0 || button < 0 || button&32 != 0 || button >= 128 { + if err != nil || xErr != nil || yErr != nil || x <= 0 || y <= 0 || button < 0 || button >= 128 { + return nil + } + return &playbackMouse{Button: button, X: x - 1, Y: y - 1, Pressed: pressed} +} + +func (m playbackMouse) key() string { + if !m.Pressed || m.Button&32 != 0 { return "" } + button := m.Button if button&64 != 0 { switch button & 3 { case 0, 3: diff --git a/internal/tui/player/terminal_keys_test.go b/internal/tui/player/terminal_keys_test.go index 3b1bd94..8969aca 100644 --- a/internal/tui/player/terminal_keys_test.go +++ b/internal/tui/player/terminal_keys_test.go @@ -1,10 +1,51 @@ package player import ( + "reflect" "slices" "testing" ) +// Keep the legacy bindings covered for renderers without a graphical bar. +func playbackKeys(input string, flush bool) (keys []string, pending string) { + inputs, pending := playbackInputs(input, flush) + for _, input := range inputs { + key := input.Key + if input.Mouse != nil { + key = input.Mouse.key() + } + if key != "" { + keys = append(keys, key) + } + } + return keys, pending +} + +func TestPlaybackMouseCoordinatesAndMetrics(t *testing.T) { + for _, tc := range []struct { + input string + want playbackInput + }{ + {"\x1b[<0;20;8M", playbackInput{Mouse: &playbackMouse{Button: 0, X: 19, Y: 7, Pressed: true}}}, + {"\x1b[<32;20;8M", playbackInput{Mouse: &playbackMouse{Button: 32, X: 19, Y: 7, Pressed: true}}}, + {"\x1b[<0;20;8m", playbackInput{Mouse: &playbackMouse{Button: 0, X: 19, Y: 7, Pressed: false}}}, + {"\x1b[6;18;9t", playbackInput{CellWidth: 9, CellHeight: 18}}, + } { + for split := 1; split < len(tc.input); split++ { + first, pending := playbackInputs(tc.input[:split], false) + last, pending := playbackInputs(pending+tc.input[split:], false) + if got := append(first, last...); pending != "" || !reflect.DeepEqual(got, []playbackInput{tc.want}) { + t.Fatalf("report %q split %d: got %+v, pending %q", tc.input, split, got, pending) + } + } + } + for _, input := range []string{"\x1b[4;900;1500t", "\x1b[?1;2c", "\x1b[6;0;8t", "\x1b[<0;bad;8M"} { + if got, _ := playbackInputs(input, true); len(got) != 0 { + t.Fatalf("terminal report leaked as input: %q: %+v", input, got) + } + } +} + func TestPlaybackKeys(t *testing.T) { keys, pending := playbackKeys(" \x1b[Cq\x1b[", false) if !slices.Equal(keys, []string{"SPACE", "RIGHT", "q"}) || pending != "\x1b[" { diff --git a/internal/tui/player/terminal_unix.go b/internal/tui/player/terminal_unix.go index a645082..1860043 100644 --- a/internal/tui/player/terminal_unix.go +++ b/internal/tui/player/terminal_unix.go @@ -4,9 +4,19 @@ package player import ( "net" + "os" "time" + + "golang.org/x/sys/unix" ) func dialTerminal(socket string) (net.Conn, error) { return net.DialTimeout("unix", socket, 100*time.Millisecond) } + +func terminalCellSize(input *os.File) (float64, float64) { + if size, err := unix.IoctlGetWinsize(int(input.Fd()), unix.TIOCGWINSZ); err == nil && size.Col > 0 && size.Row > 0 && size.Xpixel > 0 && size.Ypixel > 0 { + return float64(size.Xpixel) / float64(size.Col), float64(size.Ypixel) / float64(size.Row) + } + return 0, 0 +} diff --git a/internal/tui/player/terminal_windows.go b/internal/tui/player/terminal_windows.go index 62d7769..c9efbaa 100644 --- a/internal/tui/player/terminal_windows.go +++ b/internal/tui/player/terminal_windows.go @@ -2,6 +2,7 @@ package player import ( "net" + "os" "time" "gopkg.in/natefinch/npipe.v2" @@ -10,3 +11,6 @@ import ( func dialTerminal(socket string) (net.Conn, error) { return npipe.DialTimeout(socket, 100*time.Millisecond) } + +// Windows terminals provide pixel metrics through the terminal cell-size reply. +func terminalCellSize(*os.File) (float64, float64) { return 0, 0 } diff --git a/internal/tui/widget/playback/playback.go b/internal/tui/widget/playback/playback.go index e2f810d..2df312c 100644 --- a/internal/tui/widget/playback/playback.go +++ b/internal/tui/widget/playback/playback.go @@ -122,6 +122,7 @@ func (m *Model) View() string { detected = fmt.Sprintf("%s: text output available. Open Kitty for graphics.", name) } lines = append(lines, "", lipgloss.NewStyle().Width(m.width-4).Render(detected), "", + "Kitty: clickable controls, Tab/Enter, h to hide", "Space / click: pause Arrows / wheel: seek", "Right click / q: return", "", "[enter] Play | [esc] Cancel") if m.music { lines = lines[:3+len(m.choices)] From c53f248fd606700a0772cd0d96d7cfccde037736 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:16:29 -0400 Subject: [PATCH 04/21] feat: add live stereo waveforms and directional pane navigation --- README.md | 12 +- docs/content/config.mdx | 6 +- docs/content/tui.mdx | 43 +++-- internal/config/config.go | 2 +- internal/tui/focus.go | 106 +++++++++++++ internal/tui/focus_test.go | 97 ++++++++++++ internal/tui/music_player.go | 20 ++- internal/tui/player/audio_integration_test.go | 47 +++++- internal/tui/player/manager.go | 28 ++-- internal/tui/player/player_state.go | 1 + internal/tui/player/waveform.go | 42 +++++ internal/tui/player/waveform.lua | 61 ++++++++ internal/tui/player/window_load.go | 1 + internal/tui/tui.go | 38 ++++- internal/tui/widget/navbar/navbar.go | 43 ++++- internal/tui/widget/navbar/navbar_test.go | 35 +++++ internal/tui/widget/settings/settings.go | 1 + internal/tui/widget/visualizer/visualizer.go | 18 ++- .../tui/widget/visualizer/visualizer_test.go | 53 ++++++- internal/tui/widget/visualizer/waveform.go | 148 ++++++++++++++++++ internal/ui/mouse.go | 3 +- 21 files changed, 743 insertions(+), 62 deletions(-) create mode 100644 internal/tui/focus.go create mode 100644 internal/tui/focus_test.go create mode 100644 internal/tui/player/waveform.go create mode 100644 internal/tui/player/waveform.lua create mode 100644 internal/tui/widget/navbar/navbar_test.go create mode 100644 internal/tui/widget/visualizer/waveform.go diff --git a/README.md b/README.md index dc0d578..e0760e7 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,17 @@ options; scroll with the wheel; right click to go back. During terminal video, click to pause, scroll to seek, and right click to return. **V** switches library list/poster views. +**Ctrl+H/J/K/L** moves focus left/down/up/right between the sidebar, library, +and active player. The focused pane has a highlighted border. In the sidebar, +use **j/k** or the arrows to select a library, then **Enter** to open it. +**L** opens library settings; **Ctrl+S** opens global settings. + Movie and TV cover posters automatically use high-resolution images in Kitty 0.28+ (also through tmux), with text posters as a fallback in other terminals. See the [TUI guide](docs/content/tui.mdx) for configuration and playback details. Music libraries support artist → album → track navigation. Press **p** to play -the selected song in the TUI, with a live spectrum visualizer and no extra +the selected song in the TUI, with a live stereo waveform and no extra window. **v** chooses terminal audio or a separate window; **Ctrl+S → Music Player** saves the default. Album covers appear in the artist gallery and beside the track list. Scrolling automatically loads more artists. Playing an artist or @@ -99,8 +104,9 @@ automatically. **Home / End** reach the first/last item; **Page Up / Page Down** move by a page. The library footer shows how many items have loaded. Use **Space** to pause, **[ / ]** to seek, **+ / -** for volume, **m** to mute, -**z** to cycle spectrum/mirror/off, and **x** to stop. The visualizer measures -the playing audio and uses ordinary terminal cells, including through tmux and +**z** to cycle waveform/spectrum/mirror/off, and **x** to stop. The default +waveform draws the playing audio with cyan/violet traces and fading trails. +It uses ordinary terminal cells, including through tmux and SSH. **/** searches Plex for artists, albums, and track names without requiring a local index first. diff --git a/docs/content/config.mdx b/docs/content/config.mdx index 23b5512..ce0a008 100644 --- a/docs/content/config.mdx +++ b/docs/content/config.mdx @@ -1,6 +1,6 @@ # Configuration Reference -`plexctl` stores its configuration in a YAML file at `~/.plexctl.yaml`. While many of these settings are managed through the TUI (pressing `ctrl+s` or `ctrl+l`), you can gain granular control by editing the file directly. +`plexctl` stores its configuration in a YAML file at `~/.plexctl.yaml`. While many of these settings are managed through the TUI (pressing `Ctrl+S` or `L`), you can gain granular control by editing the file directly. ## Full Example @@ -152,8 +152,8 @@ Pressing **`ctrl+s`** at any time opens the Global Settings overlay. From here y - **Set Icon Type:** Switch between Nerd Fonts, Emojis, and ASCII icons. - **Sidebar Layout:** Adjust the `library_name_format` to show or hide icons and names. -### Library Management (`ctrl+l`) -Pressing **`ctrl+l`** opens the Library Configuration overlay. This allows you to manage the appearance of your sidebar: +### Library Management (`L`) +Pressing **`L`** opens the Library Configuration overlay. This allows you to manage the appearance of your sidebar: - **Hide Libraries:** Toggle the visibility of specific libraries (e.g., hide a "Photos" or "Home Movies" section). - **Custom Icons:** Browse and pick icons for each specific library using an integrated icon picker (supports Emoji and Nerd Fonts). - **Reorder Sidebar:** Use **`K`** and **`J`** (Shift+Up/Down) to drag and drop libraries into your preferred order. diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 7ee9957..5bad625 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -36,13 +36,25 @@ mpv's Space, Right/Left, and q bindings, including any custom bindings you set. - **`v`**: Choose a player: terminal/window for music; Automatic, Kitty, Sixel, or Text for video. - **`ctrl+p`**: Play music in the TUI or stream video with the saved terminal renderer. - **`/`**: Open global fuzzy search. -- **`ctrl+l`**: Open library configuration (show/hide/icon picker). +- **`Ctrl+H/J/K/L`**: Focus the pane to the left/down/up/right. +- **`L`**: Open library configuration (show/hide/icon picker). - **`ctrl+s`**: Open global settings (theme/icon type). - **`r`**: Refresh current view / trigger reindex (on search page). - **`x`**: Stop current playback. - **`h`**: Jump directly to Home tab. - **`?`**: Show help overlay. +**Ctrl+H** reaches the left navigation; **Ctrl+L** returns to the library. +Use **j/k** or **Up/Down** in the sidebar, then **Enter** (or **l/Right**) to open +the selected entry. The sidebar scrolls to keep the selection visible. Holding +a pane shortcut stops at the edge instead of cycling through panes. + +While playing, **Ctrl+J** moves from the library to the player below, and +**Ctrl+K** moves back up. In the player, **h/l** or **Left/Right** seek, +**j/k** or **Down/Up** adjust volume, and **Enter** pauses/resumes. **Esc** +returns focus to the library. A highlighted border and the footer identify the +focused pane; clicking a pane focuses it too. Dialogs retain their own input. + ## Features ### Fuzzy Search @@ -57,7 +69,7 @@ Rebuild an existing index from Search → Status → `r`, or run `plexctl search reindex`, to make these available to `plexctl search find`. ### Library Configuration -Pressing `ctrl+l` opens the configuration manager. Here you can: +Pressing `L` opens the configuration manager. Here you can: - **Hide/Show** specific library sections. - **Assign Custom Icons** using Emojis, Nerd Fonts, or ASCII. - **Reorder** how libraries appear in your sidebar. @@ -93,13 +105,17 @@ while listening. Press **`v`** to choose **In this terminal** or **Separate play window**. **Ctrl+P** always chooses terminal music; **Ctrl+S → Music Player** saves the default (`music_player: terminal` or `window` in the configuration). -The music player includes a 16-band audio spectrum with a color gradient, smooth -attack and release, falling peaks, and stereo level meters. These are measured -from the audio being decoded by mpv. Pause and silence settle the display; seeking -updates the spectrum to the new audio position. It uses ordinary Unicode cells -and terminal colors, so it also works in tmux and over SSH without Kitty/Sixel, -desktop audio capture, or an extra visualizer program. Audio plays on the host -running mpv. The analysis uses stereo output; mpv downmixes multichannel music. +The default visualizer is a live **stereo waveform**: cyan/azure and violet/rose +traces with fading trails and stereo meters. The trace comes from the same mpv +decoder playing the track, using an offscreen FFmpeg `showwaves` frame. Quiet +signals get a square-root amplitude scale to keep their detail visible. Pause +and silence settle the display; seeking follows the new audio position. + +Press **z** to switch to the 16-band spectrum with falling peaks, its mirrored +style, compact controls, or back to the waveform. All styles use ordinary +Unicode cells and terminal colors, including in tmux and over SSH, without +Kitty/Sixel, desktop audio capture, or an extra player window. Audio plays on +the host running mpv. The analysis uses stereo output; mpv downmixes multichannel music. | Control | Action | | --- | --- | @@ -109,13 +125,14 @@ running mpv. The analysis uses stereo output; mpv downmixes multichannel music. | Wheel over the player | Seek | | `+` / `-`, or Shift+wheel over the player | Adjust volume | | `m` | Mute/unmute | -| `z` | Cycle spectrum, mirror, and compact controls | +| `z` | Cycle waveform (default), spectrum, mirror, and compact controls | | `x` | Stop | The visualizer shrinks with the terminal and can be disabled under **Ctrl+S → -Music Visualizer** (`music_visualizer: spectrum`, `mirror`, or `off`). It requires -mpv's FFmpeg/lavfi audio filters; music playback remains available in builds -without those filters. Terminal music stops when you quit the TUI. +Music Visualizer** (`music_visualizer: waveform`, `spectrum`, `mirror`, or `off`). +The waveform requires mpv with Lua and FFmpeg/lavfi (`showwaves`); the spectrum +requires its lavfi audio filters. Music playback remains available in minimal +builds without visualization support. Terminal music stops when you quit the TUI. When playback is started, a playback bar appears at the bottom showing: - Current progress and duration. diff --git a/internal/config/config.go b/internal/config/config.go index 0c9dcb9..6b6e65f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -154,7 +154,7 @@ func Get() *Config { DefaultViewMode: ViewModePoster, TerminalVideo: VideoAuto, MusicPlayer: "terminal", - MusicVisualizer: "spectrum", + MusicVisualizer: "waveform", } }) return instance diff --git a/internal/tui/focus.go b/internal/tui/focus.go new file mode 100644 index 0000000..faddb73 --- /dev/null +++ b/internal/tui/focus.go @@ -0,0 +1,106 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/ui" +) + +type paneFocus uint8 + +const ( + paneContent paneFocus = iota + paneSidebar + panePlayer +) + +func (c *Controller) focusPane(pane paneFocus) { + if pane == panePlayer && !c.playerStatus.Active() { + pane = paneContent + } + c.focus = pane + c.tabManager.navbar.SetFocused(pane == paneSidebar) +} + +func (c *Controller) paneBorder(pane paneFocus) lipgloss.TerminalColor { + if c.focus == pane { + return ui.Accent(c.theme) + } + return c.theme.BrightBlack() +} + +// Pane keys run after modal input handling, so text fields keep their editing +// keys. Directional moves stop at the edge, including when a key is held down. +func (c *Controller) paneKey(key string) (tea.Cmd, bool) { + switch key { + case "ctrl+h": + c.focusPane(paneSidebar) + case "ctrl+l": + if c.focus == paneSidebar { + c.focusPane(paneContent) + } + case "ctrl+j": + if c.focus == paneContent && c.playerStatus.Active() { + c.focusPane(panePlayer) + } + case "ctrl+k": + if c.focus == panePlayer { + c.focusPane(paneContent) + } + default: + return c.focusedKey(key) + } + return nil, true +} + +func (c *Controller) focusedKey(key string) (tea.Cmd, bool) { + if c.focus == paneSidebar { + nav := c.tabManager.navbar + switch key { + case "j", "down": + nav.MoveCursor(1) + case "k", "up": + nav.MoveCursor(-1) + case "home": + nav.MoveCursor(-len(c.tabManager.tabModels)) + case "end": + nav.MoveCursor(len(c.tabManager.tabModels)) + case "pgup": + nav.MoveCursor(-max(1, nav.Height-10)) + case "pgdown": + nav.MoveCursor(max(1, nav.Height-10)) + case "enter", "l", "right": + cmd := c.tabManager.SetActive(nav.Cursor()) + c.focusPane(paneContent) + return cmd, true + case "esc": + c.focusPane(paneContent) + case "h", "left": // Already at the left edge. + default: + return nil, false + } + return nil, true + } + if c.focus == panePlayer { + switch key { + case "enter": + return c.player.TogglePause(), true + case "h", "left": + return c.player.Seek(-10), true + case "l", "right": + return c.player.Seek(10), true + case "k", "up": + return c.player.Volume(5), true + case "j", "down": + return c.player.Volume(-5), true + case "home": + return c.player.SeekPercent(0), true + case "end": + return c.player.SeekPercent(100), true + case "esc": + c.focusPane(paneContent) + return nil, true + } + } + return nil, false +} diff --git a/internal/tui/focus_test.go b/internal/tui/focus_test.go new file mode 100644 index 0000000..57fbb06 --- /dev/null +++ b/internal/tui/focus_test.go @@ -0,0 +1,97 @@ +package tui + +import ( + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/tui/player" + "github.com/ygelfand/plexctl/internal/tui/widget/navbar" + "github.com/ygelfand/plexctl/internal/ui" +) + +func focusController() *Controller { + nav := navbar.NewNavbar([]navbar.NavSection{{Items: []navbar.NavItem{ + {Title: "Home"}, {Title: "Movies"}, {Title: "Music"}, + }}}, ui.PlexctlTheme) + return &Controller{ + theme: ui.PlexctlTheme, + tabManager: &TabManager{ + navbar: nav, tabModels: []tea.Model{&mouseOverlay{}, &mouseOverlay{}, &mouseOverlay{}}, + }, + navigator: NewNavigator(ui.PlexctlTheme), + } +} + +func TestDirectionalPaneFocusAndKeyRepeat(t *testing.T) { + c := focusController() + c.playerStatus = player.PlayerStatus{State: operations.StatePlaying, AudioOnly: true} + for _, step := range []struct { + key tea.KeyType + pane paneFocus + }{ + {tea.KeyCtrlH, paneSidebar}, {tea.KeyCtrlL, paneContent}, + {tea.KeyCtrlJ, panePlayer}, {tea.KeyCtrlK, paneContent}, + {tea.KeyCtrlJ, panePlayer}, {tea.KeyCtrlH, paneSidebar}, + {tea.KeyCtrlJ, paneSidebar}, {tea.KeyCtrlK, paneSidebar}, + {tea.KeyCtrlL, paneContent}, + } { + for range 8 { + c.Update(tea.KeyMsg{Type: step.key}) + if c.focus != step.pane { + t.Fatalf("%v focused %v, want %v", step.key, c.focus, step.pane) + } + } + } + if c.navigator.ActiveOverlay() != nil { + t.Fatal("pane navigation opened a dialog") + } + c.playerStatus = player.PlayerStatus{} + c.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + if c.focus != paneContent { + t.Fatal("focused a missing player") + } +} + +func TestSidebarSelectionDoesNotLeakIntoContent(t *testing.T) { + c := focusController() + content := c.tabManager.ActiveModel().(*mouseOverlay) + c.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + c.Update(ui.KeyPress("j")) + c.Update(ui.KeyPress("down")) + c.Update(ui.KeyPress("k")) + if c.tabManager.navbar.Cursor() != 1 || c.tabManager.activeTabIdx != 0 || content.last != nil { + t.Fatal("sidebar navigation changed content before activation") + } + c.Update(ui.KeyPress("enter")) + if c.focus != paneContent || c.tabManager.activeTabIdx != 1 { + t.Fatal("Enter did not open the selected library and focus it") + } + c.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + if c.tabManager.navbar.Cursor() != 1 { + t.Fatal("returning to navigation lost the active library") + } +} + +func TestPaneKeysRespectDialogsAndMouseFocus(t *testing.T) { + c := focusController() + overlay := &mouseOverlay{} + c.navigator.Push(overlay) + c.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + if c.focus != paneContent || overlay.last.(tea.KeyMsg).Type != tea.KeyCtrlH { + t.Fatal("pane key escaped a modal text field") + } + c.navigator.Pop() + ui.GetLayout().Update(100, 40, false) + c.renderBaseView() + c.handleMouse(tea.MouseMsg{X: 4, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if c.focus != paneSidebar { + t.Fatal("sidebar click did not focus navigation") + } + c.handleMouse(tea.MouseMsg{ + X: ui.SidebarTotalWidth + 4, Y: 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + if c.focus != paneContent { + t.Fatal("content click did not restore keyboard focus") + } +} diff --git a/internal/tui/music_player.go b/internal/tui/music_player.go index 94d6868..4f36678 100644 --- a/internal/tui/music_player.go +++ b/internal/tui/music_player.go @@ -16,6 +16,9 @@ func (c *Controller) visualHeight(height int) int { if config.Get().MusicVisualizer == "off" || height < 20 { return 0 } + if config.Get().MusicVisualizer == "waveform" { + return max(2, min(8, (height-16)/3)) + } return max(2, min(6, (height-16)/4)) } @@ -32,6 +35,9 @@ func (c *Controller) playerHeight(height int) int { func (c *Controller) refreshPlayer() tea.Cmd { previous := c.playerStatus c.playerStatus = c.player.Status() + if c.focus == panePlayer && !c.playerStatus.Active() { + c.focusPane(paneContent) + } if previous.Key != c.playerStatus.Key { c.visualizer = visualizer.Model{} } @@ -39,6 +45,7 @@ func (c *Controller) refreshPlayer() tea.Cmd { elapsed := now.Sub(c.visualTick) c.visualTick = now c.visualizer.Step(c.playerStatus.Spectrum, c.playerStatus.Paused(), elapsed) + c.visualizer.StepWaveform(c.playerStatus.Waveform, c.playerStatus.Paused(), elapsed) if previous.Active() != c.playerStatus.Active() || previous.AudioOnly != c.playerStatus.AudioOnly { return resizePlayer() } @@ -70,9 +77,11 @@ func (c *Controller) musicControl(key string) tea.Cmd { case "mirror": cfg.MusicVisualizer = "off" case "off": - cfg.MusicVisualizer = "spectrum" - default: + cfg.MusicVisualizer = "waveform" + case "spectrum": cfg.MusicVisualizer = "mirror" + default: + cfg.MusicVisualizer = "spectrum" } _ = cfg.Save() return resizePlayer() @@ -95,7 +104,6 @@ func (c *Controller) musicControls() string { func (c *Controller) renderMusicPlayer() string { layout := ui.GetLayout() width := max(1, layout.InnerWidth()) - accent := ui.Accent(c.theme) state := "PLAYING" if c.playerStatus.Paused() { state = "PAUSED" @@ -107,13 +115,13 @@ func (c *Controller) renderMusicPlayer() string { lines := []string{ui.Ellipsis(title, width)} if height := c.visualHeight(layout.TotalHeight()); height > 0 { mode := config.Get().MusicVisualizer - if mode != "mirror" { - mode = "spectrum" + if mode != "mirror" && mode != "spectrum" { + mode = "waveform" } lines = append(lines, c.visualizer.View(width, height, mode)) } lines = append(lines, c.musicProgress(width), ui.Ellipsis(c.musicControls(), width)) - return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent). + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(c.paneBorder(panePlayer)). Padding(0, 1).Width(width + 2).Render(strings.Join(lines, "\n")) } diff --git a/internal/tui/player/audio_integration_test.go b/internal/tui/player/audio_integration_test.go index 3082269..fadfe6d 100644 --- a/internal/tui/player/audio_integration_test.go +++ b/internal/tui/player/audio_integration_test.go @@ -92,10 +92,26 @@ func TestAudioPlaybackAndSpectrum(t *testing.T) { if msg := pm.Play(media, "Audio QA", "qa", true, config.AudioTerminal, 0, nil)(); msg != nil { t.Fatalf("play: %v", msg) } - status := waitAudio(t, pm, "audio starts", func(s PlayerStatus) bool { return s.Time > 0.2 && s.Spectrum.Valid }) + status := waitAudio(t, pm, "audio and waveform start", func(s PlayerStatus) bool { + return s.Time > 0.2 && s.Spectrum.Valid && s.Waveform.Valid + }) if !status.AudioOnly || status.Spectrum.Left <= status.Spectrum.Right+5 { t.Fatal("stereo levels did not preserve the source's 6 dB channel difference") } + wavePeak := func(samples [WaveformSamples]float64) float64 { + peak := 0.0 + for _, value := range samples { + peak = max(peak, math.Abs(value)) + } + return peak + } + left, right := wavePeak(status.Waveform.Left), wavePeak(status.Waveform.Right) + if left < .1 || right < .05 || left < right*1.2 { + t.Fatalf("waveform did not reflect stereo amplitudes: left %.3f, right %.3f", left, right) + } + if status.Waveform.Left == status.Waveform.Right { + t.Fatal("independent stereo waveforms were lost") + } peak := func(s PlayerStatus) int { band := 0 for i, value := range s.Spectrum.Bands { @@ -110,16 +126,18 @@ func TestAudioPlaybackAndSpectrum(t *testing.T) { } pm.ioMu.Lock() var window, video bool + var vo string _ = pm.conn.get("force-window", &window) _ = pm.conn.get("vo-configured", &video) + _ = pm.conn.get("current-vo", &vo) pm.ioMu.Unlock() - if window || video { + if window || video && vo != "null" { t.Fatal("terminal music enabled a video window") } pm.TogglePause()() paused := waitAudio(t, pm, "pause", func(s PlayerStatus) bool { return s.Paused() }) time.Sleep(150 * time.Millisecond) - if math.Abs(pm.Status().Time-paused.Time) > 0.01 { + if math.Abs(pm.Status().Time-paused.Time) > 0.01 || pm.Status().Waveform.Serial != paused.Waveform.Serial { t.Fatal("paused playback kept advancing") } pm.Volume(-10)() @@ -129,17 +147,34 @@ func TestAudioPlaybackAndSpectrum(t *testing.T) { pm.SeekPercent(50)() pm.TogglePause()() waitAudio(t, pm, "seek updates frequencies", func(s PlayerStatus) bool { - return s.Time >= 3 && s.Spectrum.Valid && peak(s) == 10 + return s.Time >= 3 && s.Spectrum.Valid && peak(s) == 10 && + s.Waveform.Valid && s.Waveform.Serial > status.Waveform.Serial && + s.Waveform.Left != status.Waveform.Left }) pm.SeekPercent(85)() waitAudio(t, pm, "silence", func(s PlayerStatus) bool { - return s.Time >= 5 && s.Spectrum.Valid && s.Spectrum.Left < -90 + return s.Time >= 5 && s.Spectrum.Valid && s.Spectrum.Left < -90 && + s.Waveform.Valid && wavePeak(s.Waveform.Left) < .04 && wavePeak(s.Waveform.Right) < .04 + }) + // Reload on the same mpv connection: per-file graphs and script hooks must + // reset, even after seeking into silence on the previous track. + if msg := pm.Play(media, "Next track", "qa-next", true, config.AudioTerminal, 0, nil)(); msg != nil { + t.Fatalf("next track: %v", msg) + } + waitAudio(t, pm, "next track waveform", func(s PlayerStatus) bool { + return s.Key == "qa-next" && s.Time > .2 && s.Time < 2 && + s.Waveform.Valid && wavePeak(s.Waveform.Left) > .1 }) pm.StopPlayback()() stopped := pm.Status() - if stopped.Active() || stopped.Spectrum.Valid { + if stopped.Active() || stopped.Spectrum.Valid || stopped.Waveform.Valid { t.Fatal("stop left audio or visualization active") } + scriptDir := pm.waveformDir + pm.Close() + if _, err := os.Stat(scriptDir); !os.IsNotExist(err) { + t.Fatal("waveform script directory was not cleaned up") + } } func TestSpectrumFilterPreservesStereoSamples(t *testing.T) { diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index 8ff6a4e..a081b98 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -19,16 +19,17 @@ import ( type PlayerManager struct { // ioMu serializes commands and lifecycle changes. Rendering only takes // statusMu, so a slow IPC request never blocks the terminal UI. - ioMu sync.Mutex - conn *ipcClient - cancel context.CancelFunc - socketPath string - statusMu sync.RWMutex - status PlayerStatus - updates chan tea.Msg - done chan struct{} - closeOnce sync.Once - workers sync.WaitGroup + ioMu sync.Mutex + conn *ipcClient + cancel context.CancelFunc + socketPath string + waveformDir string + statusMu sync.RWMutex + status PlayerStatus + updates chan tea.Msg + done chan struct{} + closeOnce sync.Once + workers sync.WaitGroup } var ( @@ -114,6 +115,7 @@ func (pm *PlayerManager) start( if audioOnly { // Minimal builds may lack lavfi. Playback still works without meters. _ = pm.conn.call("af", "add", spectrumFilter()) + _ = pm.ensureWaveformScript() } if err := pm.conn.command(mediaLoadCommand(url, startOffset, audioOnly), nil); err != nil { return previous, err @@ -301,6 +303,9 @@ func (pm *PlayerManager) Close() { pm.disconnect() pm.ioMu.Unlock() pm.workers.Wait() + if pm.waveformDir != "" { + _ = os.RemoveAll(pm.waveformDir) + } }) } @@ -347,10 +352,13 @@ func (pm *PlayerManager) monitor(ctx context.Context, conn *ipcClient, reports c var metadata map[string]string _ = conn.get("af-metadata/plexctl-spectrum", &metadata) status.Spectrum = readSpectrum(metadata) + status.Waveform = Waveform{} + _ = conn.get("user-data/plexctl-waveform", &status.Waveform) } } else { status.State = operations.StateStopped status.Spectrum = Spectrum{} + status.Waveform = Waveform{} } pm.setStatus(status) if err != nil { diff --git a/internal/tui/player/player_state.go b/internal/tui/player/player_state.go index 6229d80..5e7da7c 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -14,6 +14,7 @@ type PlayerStatus struct { Volume float64 Muted bool Spectrum Spectrum + Waveform Waveform } type ExternalSubtitle struct { diff --git a/internal/tui/player/waveform.go b/internal/tui/player/waveform.go new file mode 100644 index 0000000..112eb1f --- /dev/null +++ b/internal/tui/player/waveform.go @@ -0,0 +1,42 @@ +package player + +import ( + _ "embed" + "os" + "path/filepath" +) + +const WaveformSamples = 256 + +// Waveform is a live stereo trace from the playing decoder's showwaves output. +// Fixed arrays bound IPC/rendering memory and make player status safe to copy. +type Waveform struct { + Left [WaveformSamples]float64 `json:"left"` + Right [WaveformSamples]float64 `json:"right"` + Serial int64 `json:"serial"` + Valid bool `json:"valid"` +} + +//go:embed waveform.lua +var waveformScript []byte + +// Called with ioMu held. Lua can read mpv's raw video frame in memory; JSON IPC +// cannot carry that binary node. Only the small stereo trace crosses IPC. +func (pm *PlayerManager) ensureWaveformScript() error { + var version int + if pm.conn.get("user-data/plexctl-waveform-version", &version) == nil && version == 1 { + return nil + } + if pm.waveformDir == "" { + dir, err := os.MkdirTemp("", "plexctl-waveform-") + if err != nil { + return err + } + pm.waveformDir = dir + } + path := filepath.Join(pm.waveformDir, "plexctl-waveform.lua") + if err := os.WriteFile(path, waveformScript, 0o600); err != nil { + return err + } + return pm.conn.call("load-script", path) +} diff --git a/internal/tui/player/waveform.lua b/internal/tui/player/waveform.lua new file mode 100644 index 0000000..e57334d --- /dev/null +++ b/internal/tui/player/waveform.lua @@ -0,0 +1,61 @@ +-- A single decoder supplies both audible music and a tiny offscreen waveform. +-- The null video output never creates a window or writes to the terminal. +local mp = require 'mp' +local ready = not mp.get_property_bool('idle-active', true) +local configured, failed = false, false +local serial = 0 +local width, height = 256, 128 + +local function clear() + configured, failed = false, false + mp.set_property_native('user-data/plexctl-waveform', {valid=false}) +end + +mp.register_event('start-file', function() ready=false; clear() end) +mp.register_event('file-loaded', function() ready=true end) +mp.register_event('end-file', function() ready=false; clear() end) +mp.register_event('seek', function() + mp.set_property_native('user-data/plexctl-waveform', {valid=false}) +end) + +mp.add_periodic_timer(0.05, function() + if not ready or failed or not mp.get_property_bool('user-data/plex-audio-only', false) then return end + if not configured then + local aid = mp.get_property_number('aid') + if not aid or aid < 1 then return end + -- Only the visualization branch is converted to s16 for showwaves. + -- The [ao] branch retains the original audio samples and channel layout. + local graph = string.format( + '[aid%d]asplit[ao][wave];[wave]aformat=channel_layouts=stereo:sample_fmts=s16,' .. + 'showwaves=s=%dx%d:mode=p2p:r=20:split_channels=1:colors=white:scale=sqrt:draw=full[vo]', + aid, width, height) + local ok = mp.set_property('file-local-options/lavfi-complex', graph) + if not ok then failed=true; return end + configured = true + end + if mp.get_property_bool('pause') or not mp.get_property_bool('vo-configured') then return end + local img = mp.command_native({'screenshot-raw', 'video'}) + if not img or type(img.data) ~= 'string' or img.w ~= width or img.h ~= height + or img.stride < width*4 or #img.data < img.stride*height then return end + + local left, right = {}, {} + for x=0,width-1 do + for channel=0,1 do + local first, last + for y=channel*height/2,(channel+1)*height/2-1 do + local offset = y*img.stride+x*4+1 + -- White showwaves pixels have equal B, G, R; one byte suffices. + if img.data:byte(offset) > 20 then first=first or y; last=y end + end + local center = (channel+0.5)*height/2 + local value = first and (center-(first+last)/2)/(height/4) or 0 + if channel == 0 then left[x+1]=value else right[x+1]=value end + end + end + serial = serial+1 + mp.set_property_native('user-data/plexctl-waveform', { + left=left, right=right, serial=serial, valid=true + }) +end) + +mp.set_property_native('user-data/plexctl-waveform-version', 1) diff --git a/internal/tui/player/window_load.go b/internal/tui/player/window_load.go index 0e5bbfb..48ca0b6 100644 --- a/internal/tui/player/window_load.go +++ b/internal/tui/player/window_load.go @@ -52,6 +52,7 @@ func mediaLoadCommand(url string, startOffset int64, audioOnly bool) map[string] if audioOnly { options["vid"] = "no" options["force-window"] = "no" + options["vo"] = "null" } return map[string]any{"name": "loadfile", "url": url, "flags": "replace", "options": options} } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0f7f237..a40e1cd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -45,6 +45,7 @@ type Controller struct { returnTabIdx int visualizer visualizer.Model visualTick time.Time + focus paneFocus } type switchUserSuccessMsg struct{} @@ -246,6 +247,9 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: slog.Log(context.Background(), config.LevelTrace, "TUI: key press", "key", msg.String()) + if cmd, handled := c.paneKey(msg.String()); handled { + return c, cmd + } switch msg.String() { case "g": if target, ok := c.tabManager.ActiveModel().(ui.LetterJumper); ok && target.CanJumpLetters() { @@ -258,7 +262,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, tea.Quit case "ctrl+s": return c, c.navigator.Push(settings.NewSettingsOverlayModel(c.theme)) - case "ctrl+l": + case "L": return c, c.navigator.Push(tuiconfig.NewConfigOverlayModel(c.data.Libraries)) case "h": return c, c.tabManager.SetActive(0) @@ -313,6 +317,9 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "shift+tab": return c, c.tabManager.PrevTab() } + if c.focus != paneContent { + return c, nil + } } var alertCmd tea.Cmd @@ -516,18 +523,20 @@ func (c *Controller) showHelp() tea.Cmd { {Key: "wheel", Desc: "Scroll under pointer; Shift+wheel browses a poster row"}, {Key: "right click", Desc: "Back / close dialog (Shift bypasses mouse capture)"}, {Key: "tab", Desc: "Switch Library"}, + {Key: "ctrl+h / j / k / l", Desc: "Focus pane: left / down / up / right"}, + {Key: "j / k, enter", Desc: "In navigation: select a library, then open it"}, {Key: "g, then A–Z / #", Desc: "Jump to a letter (or click the right-hand alphabet)"}, {Key: "Home / End / PgUp / PgDn", Desc: "First / last item / previous / next page"}, {Key: "h", Desc: "Home"}, {Key: "ctrl+s", Desc: "Global Settings"}, - {Key: "ctrl+l", Desc: "Libraries"}, + {Key: "L", Desc: "Libraries"}, {Key: "u", Desc: "Switch User"}, {Key: "p", Desc: "Play Selected"}, {Key: "ctrl+p", Desc: "Play in Terminal"}, {Key: "v", Desc: "Choose player (music: terminal / window; video: renderer)"}, {Key: "[ / ]", Desc: "Seek backward / forward 10 seconds"}, {Key: "+ / - / m", Desc: "Volume up / down / mute"}, - {Key: "z", Desc: "Cycle music visualizer: spectrum / mirror / off"}, + {Key: "z", Desc: "Cycle music visualizer: waveform / spectrum / mirror / off"}, {Key: "x", Desc: "Stop Playback"}, {Key: "q", Desc: "Quit"}, {Key: "?", Desc: "Help"}, @@ -560,6 +569,7 @@ func (c *Controller) renderBaseView() string { mainHeight := max(layout.TotalHeight()-1, 0) c.tabManager.navbar.Height = mainHeight + c.tabManager.navbar.SetFocused(c.focus == paneSidebar) sidebar := c.tabManager.navbar.View() // The content box should fill the remaining height @@ -568,7 +578,7 @@ func (c *Controller) renderBaseView() string { windowStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true). - BorderForeground(c.theme.BrightBlack()). + BorderForeground(c.paneBorder(paneContent)). Padding(0, 1). Width(layout.MainAreaContentWidth() + 2). Height(contentHeight) @@ -599,9 +609,15 @@ func (c *Controller) renderBaseView() string { } func (c *Controller) footerText() string { - text := "[p] Play | [v] Player | [V] View | [/] Search | [?] Help | [q] Quit" + text := "Ctrl+HJKL: panes | [p] Play | [v] Player | [V] View | [/] Search | [?] Help | [q] Quit" if c.playerStatus.Active() { - text = "[p] Play | [v] Player | [x] Stop | [/] Search | [?] Help | [q] Quit" + text = "Ctrl+HJKL: panes | [p] Play | [v] Player | [x] Stop | [/] Search | [?] Help | [q] Quit" + } + switch c.focus { + case paneSidebar: + text = "Navigation | j/k: select | [enter] Open | Ctrl+L: content | [L] Libraries | [?] Help" + case panePlayer: + text = "Player | h/l: seek | j/k: volume | [space] Pause | Ctrl+K: content | [?] Help" } return ui.Ellipsis(text, max(1, ui.GetLayout().TotalWidth()-2)) } @@ -615,6 +631,7 @@ func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { if delta := ui.WheelDelta(mouse); delta != 0 { c.tabManager.navbar.Scroll(delta * 3) } else if ui.LeftClick(mouse) { + c.focusPane(paneSidebar) if index := c.tabManager.navbar.ItemAt(mouse); index >= 0 { return c, c.tabManager.SetActive(index) } @@ -638,10 +655,16 @@ func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { if !inside { if c.playerStatus.Active() && mouse.X > ui.SidebarTotalWidth && mouse.Y >= rect.Height+2 && mouse.Y < layout.TotalHeight()-1 { + if ui.LeftClick(mouse) { + c.focusPane(panePlayer) + } return c, c.playerMouse(mouse, rect.Height+2) } return c, nil } + if ui.LeftClick(mouse) { + c.focusPane(paneContent) + } if model := c.tabManager.ActiveModel(); model != nil { // Detail action bars appear at the bottom of their own view. view := model.View() @@ -687,7 +710,8 @@ func (c *Controller) renderPlayer() string { timeStr := fmt.Sprintf("%s / %s", ui.FormatDuration(int(c.playerStatus.Time*1000)), ui.FormatDuration(int(c.playerStatus.Duration*1000))) title := lipgloss.NewStyle().Foreground(c.theme.BrightYellow()).Bold(true).Width(width - 2).Render(c.playerStatus.Title) - playerStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).BorderForeground(accent).Width(width).Padding(0, 1) + playerStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true). + BorderForeground(c.paneBorder(panePlayer)).Width(width).Padding(0, 1) row2 := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(c.theme.BrightCyan()).Width(12).Render(status), diff --git a/internal/tui/widget/navbar/navbar.go b/internal/tui/widget/navbar/navbar.go index ef29085..23dee83 100644 --- a/internal/tui/widget/navbar/navbar.go +++ b/internal/tui/widget/navbar/navbar.go @@ -26,6 +26,8 @@ type NavSection struct { type Navbar struct { sections []NavSection activeFlatIdx int + cursor int + focused bool theme tint.Tint Width int Height int @@ -64,6 +66,26 @@ func NewNavbar(sections []NavSection, theme tint.Tint) *Navbar { func (n *Navbar) SetActive(index int) { n.activeFlatIdx = index + n.cursor = index + n.followActive = true +} + +func (n *Navbar) SetFocused(focused bool) { + if focused && !n.focused { + n.cursor = n.activeFlatIdx + n.followActive = true + } + n.focused = focused +} + +func (n *Navbar) Cursor() int { return n.cursor } + +func (n *Navbar) MoveCursor(delta int) { + count := 0 + for _, section := range n.sections { + count += len(section.Items) + } + n.cursor = max(0, min(count-1, n.cursor+delta)) n.followActive = true } @@ -133,6 +155,7 @@ func (n *Navbar) View() string { Border(lipgloss.Border{Left: "┃"}, false, false, false, true). BorderForeground(accent) + cursorStyle := activeStyle.Foreground(n.theme.Black()).Background(accent) sectionHeaderStyle := lipgloss.NewStyle(). Foreground(n.theme.BrightBlack()). @@ -149,7 +172,9 @@ func (n *Navbar) View() string { content := ui.Ellipsis(n.formatItem(item), max(1, n.Width-5)) y := lipgloss.Height(lipgloss.JoinVertical(lipgloss.Left, renderedLines...)) n.itemRows = append(n.itemRows, ui.Rect{Y: y, Width: n.Width, Height: 1}) - if flatIdx == n.activeFlatIdx { + if n.focused && flatIdx == n.cursor { + renderedLines = append(renderedLines, cursorStyle.Render(content)) + } else if flatIdx == n.activeFlatIdx { renderedLines = append(renderedLines, activeStyle.Render(content)) } else { renderedLines = append(renderedLines, baseStyle.Render(content)) @@ -159,13 +184,17 @@ func (n *Navbar) View() string { } lines := strings.Split(lipgloss.JoinVertical(lipgloss.Left, renderedLines...), "\n") - n.actionRows = []string{"", " [/] Search", " [ctrl+s] Settings", " [ctrl+l] Libraries", " [u] Switch user"} + n.actionRows = []string{"", " [/] Search", " [ctrl+s] Settings", " [L] Libraries", " [u] Switch user"} if n.Height < 12 { n.actionRows = nil } n.actionTop = max(1, n.Height-len(n.actionRows)) - if n.followActive && n.activeFlatIdx >= 0 && n.activeFlatIdx < len(n.itemRows) { - y := n.itemRows[n.activeFlatIdx].Y + followIdx := n.activeFlatIdx + if n.focused { + followIdx = n.cursor + } + if n.followActive && followIdx >= 0 && followIdx < len(n.itemRows) { + y := n.itemRows[followIdx].Y if y < n.offset { n.offset = y } else if y >= n.offset+n.actionTop { @@ -183,13 +212,17 @@ func (n *Navbar) View() string { } visible = append(visible, n.actionRows...) sidebarContent := strings.Join(visible, "\n") + border := n.theme.BrightBlack() + if n.focused { + border = accent + } // Container style with right border and fixed height return lipgloss.NewStyle(). Width(n.Width). Height(n.Height). Border(lipgloss.NormalBorder(), false, true, false, false). - BorderForeground(n.theme.BrightBlack()). + BorderForeground(border). Render(sidebarContent) } diff --git a/internal/tui/widget/navbar/navbar_test.go b/internal/tui/widget/navbar/navbar_test.go new file mode 100644 index 0000000..a6e8861 --- /dev/null +++ b/internal/tui/widget/navbar/navbar_test.go @@ -0,0 +1,35 @@ +package navbar + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestKeyboardCursorRemainsVisibleInShortSidebar(t *testing.T) { + items := make([]NavItem, 60) + for i := range items { + items[i] = NavItem{Title: fmt.Sprintf("Library %d", i)} + } + n := NewNavbar([]NavSection{{Title: "Libraries", Items: items}}, ui.PlexctlTheme) + n.Height = 16 + n.SetFocused(true) + for range 75 { + n.MoveCursor(1) + n.View() + row := n.itemRows[n.Cursor()] + if row.Y < 0 || row.Y >= n.actionTop || n.ItemAt(tea.MouseMsg{Y: row.Y}) != n.Cursor() { + t.Fatalf("cursor %d is not visible or clickable", n.Cursor()) + } + } + if n.Cursor() != 59 || n.activeFlatIdx != 0 { + t.Fatal("cursor did not stop at the last library independently of the active tab") + } + n.MoveCursor(-100) + n.View() + if n.Cursor() != 0 || n.itemRows[0].Y < 0 { + t.Fatal("could not navigate back to the first library") + } +} diff --git a/internal/tui/widget/settings/settings.go b/internal/tui/widget/settings/settings.go index 070536e..4abe95b 100644 --- a/internal/tui/widget/settings/settings.go +++ b/internal/tui/widget/settings/settings.go @@ -242,6 +242,7 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { case "music_visualizer": m.selectionList.Title = "Choose Music Visualizer" items = []list.Item{ + selectionItem{id: "waveform", value: "Waveform · live stereo waves and glowing trails (default)"}, selectionItem{id: "spectrum", value: "Spectrum · gradient bars and falling peaks"}, selectionItem{id: "mirror", value: "Mirror · symmetric spectrum"}, selectionItem{id: "off", value: "Off · compact controls"}, diff --git a/internal/tui/widget/visualizer/visualizer.go b/internal/tui/widget/visualizer/visualizer.go index 7ae2146..455eefc 100644 --- a/internal/tui/widget/visualizer/visualizer.go +++ b/internal/tui/widget/visualizer/visualizer.go @@ -14,12 +14,15 @@ import ( ) type Model struct { - levels [player.SpectrumBands]float64 - peaks [player.SpectrumBands]float64 - hold [player.SpectrumBands]float64 - left float64 - right float64 - valid bool + levels [player.SpectrumBands]float64 + peaks [player.SpectrumBands]float64 + hold [player.SpectrumBands]float64 + left float64 + right float64 + valid bool + waves [3]player.Waveform + waveFade float64 + waveReady bool } func amplitude(db float64) float64 { @@ -71,6 +74,9 @@ func (m *Model) View(width, height int, mode string) string { if width < 1 || height < 1 { return "" } + if mode == "waveform" { + return m.waveformView(width, height) + } count := max(1, min(player.SpectrumBands, width/3)) step := max(1, width/count) barWidth := max(1, step-1) diff --git a/internal/tui/widget/visualizer/visualizer_test.go b/internal/tui/widget/visualizer/visualizer_test.go index 1b2c62d..cf9da08 100644 --- a/internal/tui/widget/visualizer/visualizer_test.go +++ b/internal/tui/widget/visualizer/visualizer_test.go @@ -1,6 +1,7 @@ package visualizer import ( + "math" "strings" "testing" "time" @@ -31,9 +32,10 @@ func TestVisualizerRespondsToAudioAndSettlesOnPause(t *testing.T) { } func TestVisualizerFitsTerminalSizes(t *testing.T) { - for _, mode := range []string{"spectrum", "mirror"} { + for _, mode := range []string{"waveform", "spectrum", "mirror"} { t.Run(mode, func(t *testing.T) { var m Model + m.StepWaveform(testWave(), false, 50*time.Millisecond) for _, width := range []int{1, 12, 40, 53, 130} { for _, height := range []int{1, 2, 6} { view := m.View(width, height, mode) @@ -50,3 +52,52 @@ func TestVisualizerFitsTerminalSizes(t *testing.T) { }) } } + +func testWave() player.Waveform { + frame := player.Waveform{Valid: true, Serial: 1} + for i := range frame.Left { + frame.Left[i] = math.Sin(float64(i) * .07) + frame.Right[i] = math.Cos(float64(i)*.12) * .5 + } + return frame +} + +func TestWaveformFollowsSamplesAndSettles(t *testing.T) { + var m Model + frame := testWave() + m.StepWaveform(frame, false, 50*time.Millisecond) + live := m.View(80, 8, "waveform") + if !strings.Contains(live, "LIVE WAVEFORM") || m.waves[0] != frame { + t.Fatal("waveform did not use the decoded stereo samples") + } + for range 50 { + m.StepWaveform(frame, true, 50*time.Millisecond) + } + if m.waveFade > .001 || m.View(80, 8, "waveform") == live { + t.Fatal("pause did not settle the waveform") + } + for i := range 4 { + m.StepWaveform(player.Waveform{Valid: true, Serial: int64(i + 2)}, false, 50*time.Millisecond) + } + for _, wave := range m.waves { + if wave.Left != [player.WaveformSamples]float64{} || wave.Right != wave.Left { + t.Fatal("silence retained an invented waveform") + } + } + for range 10 { + m.StepWaveform(player.Waveform{Valid: true, Serial: 5}, false, 50*time.Millisecond) + } + if m.waves[1].Serial == m.waves[0].Serial { + t.Fatal("repeated polling incorrectly advanced waveform history") + } +} + +func TestWaveSampleRejectsInvalidValues(t *testing.T) { + var samples [player.WaveformSamples]float64 + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + samples[0] = value + if waveSample(&samples, 0) != 0 { + t.Fatal("invalid amplitude escaped into rendering") + } + } +} diff --git a/internal/tui/widget/visualizer/waveform.go b/internal/tui/widget/visualizer/waveform.go new file mode 100644 index 0000000..f242dc0 --- /dev/null +++ b/internal/tui/widget/visualizer/waveform.go @@ -0,0 +1,148 @@ +package visualizer + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/tui/player" + "github.com/ygelfand/plexctl/internal/ui" +) + +// StepWaveform retains short phosphor trails of the actual stereo trace. There +// is no time-driven oscillator: pause/silence settle instead of inventing sound. +func (m *Model) StepWaveform(frame player.Waveform, paused bool, elapsed time.Duration) { + dt := max(0, min(.25, elapsed.Seconds())) + if paused || !frame.Valid { + m.waveFade *= math.Exp(-dt / .16) + return + } + m.waveReady = true + if frame.Serial != m.waves[0].Serial { + m.waves[2], m.waves[1], m.waves[0] = m.waves[1], m.waves[0], frame + } + m.waveFade += (1 - m.waveFade) * (1 - math.Exp(-dt/.035)) +} + +func waveSample(samples *[player.WaveformSamples]float64, at float64) float64 { + i := min(player.WaveformSamples-1, max(0, int(at))) + next := min(player.WaveformSamples-1, i+1) + v := samples[i] + (samples[next]-samples[i])*(at-float64(i)) + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return max(-1, min(1, v)) +} + +func waveColor(channel, position, strength int) string { + // Cyan/azure for left; violet/rose for right. Overlapping live traces glow. + colors := [2][3][3]int{ + {{34, 211, 166}, {56, 181, 255}, {129, 220, 255}}, + {{148, 112, 255}, {232, 104, 223}, {255, 178, 214}}, + } + index := min(1, position/8) + t := float64(position%8) / 8 + brightness := []float64{.18, .32, .56, 1}[min(3, strength)] + values := [3]int{} + for i := range values { + values[i] = int((float64(colors[channel][index][i])*(1-t) + float64(colors[channel][index+1][i])*t) * brightness) + } + return fmt.Sprintf("#%02x%02x%02x", values[0], values[1], values[2]) +} + +func (m *Model) waveformView(width, height int) string { + // Braille gives 2×4 addressable points per cell on Kitty, plain terminals, + // tmux and SSH. No graphics protocol, sixel, or local audio device is needed. + pw, ph := width*2, height*4 + pixels := make([]uint8, pw*ph) + point := func(x, y, channel, strength int) { + if x < 0 || x >= pw || y < 0 || y >= ph { + return + } + at := y*pw + x + value := uint8(strength | channel<<2) + if int(pixels[at]&3) <= strength { + pixels[at] = value + } + } + separate := height >= 4 + for age := 2; age >= 0; age-- { + frame := &m.waves[age] + if !frame.Valid { + continue + } + for channel := range 2 { + samples := &frame.Left + if channel == 1 { + samples = &frame.Right + } + center, span := float64(ph-1)/2, float64(ph-2)/2 + if separate { + center = float64(channel*ph/2) + float64(ph/2-1)/2 + span = float64(ph/2-2) / 2 + } + previous := -1 + for x := range pw { + v := waveSample(samples, float64(x)*float64(player.WaveformSamples-1)/float64(max(1, pw-1))) + y := int(math.Round(center - v*span*m.waveFade)) + if previous < 0 { + previous = y + } + for p := min(previous, y); p <= max(previous, y); p++ { + point(x, p, channel, 3-age) + } + previous = y + } + } + } + styles := [2][16][4]lipgloss.Style{} + for channel := range 2 { + for pos := range 16 { + for strength := range 4 { + styles[channel][pos][strength] = lipgloss.NewStyle().Foreground(lipgloss.Color(waveColor(channel, pos, strength))) + } + } + } + bits := [4][2]rune{{1, 8}, {2, 16}, {4, 32}, {64, 128}} + rows := make([]string, 0, height+1) + for row := range height { + var line strings.Builder + for col := range width { + var glyph rune + strength, channel := 0, 0 + for y := range 4 { + for x := range 2 { + value := pixels[(row*4+y)*pw+col*2+x] + if value&3 == 0 { + continue + } + glyph |= bits[y][x] + if int(value&3) >= strength { + strength, channel = int(value&3), int(value>>2) + } + } + } + if glyph == 0 { + if row == height/2 && col%4 == 0 { + line.WriteString(styles[0][0][0].Render("·")) + } else { + line.WriteByte(' ') + } + } else { + line.WriteString(styles[channel][min(15, col*16/width)][strength].Render(string(0x2800 + glyph))) + } + } + rows = append(rows, line.String()) + } + label := "LIVE WAVEFORM · L / R" + if !m.waveReady { + label = "Waiting for waveform · z: spectrum" + } + meters := meter("L", m.left, min(8, max(1, width/12)), palette[3]) + " " + meter("R", m.right, min(8, max(1, width/12)), palette[11]) + gap := max(1, width-lipgloss.Width(meters)-lipgloss.Width(label)) + legend := meters + strings.Repeat(" ", gap) + lipgloss.NewStyle().Foreground(lipgloss.Color("#7391ab")).Render(label) + rows = append(rows, ui.Ellipsis(legend, width)) + return strings.Join(rows, "\n") +} diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index b403b08..aa625b5 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -50,7 +50,8 @@ func KeyPress(key string) tea.KeyMsg { "enter": tea.KeyEnter, "esc": tea.KeyEsc, "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, "tab": tea.KeyTab, "home": tea.KeyHome, "end": tea.KeyEnd, "pgup": tea.KeyPgUp, "pgdown": tea.KeyPgDown, - "ctrl+s": tea.KeyCtrlS, "ctrl+l": tea.KeyCtrlL, + "ctrl+s": tea.KeyCtrlS, "ctrl+h": tea.KeyCtrlH, "ctrl+j": tea.KeyCtrlJ, + "ctrl+k": tea.KeyCtrlK, "ctrl+l": tea.KeyCtrlL, "space": tea.KeySpace, "↑": tea.KeyUp, "↓": tea.KeyDown, "arrows": tea.KeyTab, } if t, ok := types[key]; ok { From 9b2af6df8fbf5d7289ef92dee9616258b3f3cf25 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:05:57 -0400 Subject: [PATCH 05/21] go mod tidy --- go.mod | 5 ++--- go.sum | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 16e85c6..856d1e2 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 github.com/kyokomi/emoji/v2 v2.2.13 github.com/lovelydeng/gomoji v0.0.0-20221120141925-ea446dc92ac0 github.com/olekukonko/tablewriter v1.1.3 @@ -69,7 +68,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 github.com/muesli/termenv v0.16.0 // indirect @@ -92,7 +91,7 @@ require ( golang.org/x/crypto v0.51.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 golang.org/x/tools v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index b4318b9..2496873 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/doordash-oss/oapi-codegen-dd/v3 v3.66.2 h1:fPEk1VNxZL/ygdYlldIu2SWHv+ldBLO4lMCg4yOfD5E= github.com/doordash-oss/oapi-codegen-dd/v3 v3.66.2/go.mod h1:79IrCPlLSRQqfh7dyY58Z+EN98wg8fIInAPaYtj7pOo= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= From 20376d3efc3651a700d3d6ad2cb24e96fe77b6b8 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:06:13 -0400 Subject: [PATCH 06/21] go fix ./... --- cmd/history.go | 8 ++++---- cmd/search.go | 2 +- internal/cache/cache.go | 4 ++-- internal/plex/loader.go | 2 +- internal/presenters/collections.go | 4 ++-- internal/presenters/history.go | 2 +- internal/presenters/library.go | 6 +++--- internal/presenters/presenter.go | 2 +- internal/presenters/server.go | 10 +++++----- internal/presenters/session.go | 2 +- internal/presenters/simple.go | 4 ++-- internal/presenters/user.go | 4 ++-- internal/tui/splash.go | 17 ++++------------- internal/tui/tui.go | 2 +- internal/tui/view/detail/music_detail_test.go | 4 ++-- internal/tui/view/history.go | 2 +- internal/tui/widget/config/mouse_test.go | 4 ++-- internal/tui/widget/iconpicker/nerdfont.go | 4 ++-- internal/tui/widget/poster/grid.go | 5 +---- internal/tui/widget/poster/item.go | 5 +---- internal/tui/widget/poster/list.go | 5 +---- .../tui/widget/visualizer/visualizer_test.go | 2 +- internal/ui/mouse.go | 6 +++--- internal/ui/styles.go | 2 +- 24 files changed, 45 insertions(+), 63 deletions(-) diff --git a/cmd/history.go b/cmd/history.go index 0b100c1..b889801 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -27,13 +27,13 @@ var historyCmd = &cobra.Command{ if since != "" { d, err := time.ParseDuration(since) if err != nil { - if strings.HasSuffix(since, "d") { - val := strings.TrimSuffix(since, "d") + if before, ok := strings.CutSuffix(since, "d"); ok { + val := before var days int fmt.Sscanf(val, "%d", &days) d = time.Duration(days) * 24 * time.Hour - } else if strings.HasSuffix(since, "w") { - val := strings.TrimSuffix(since, "w") + } else if before, ok := strings.CutSuffix(since, "w"); ok { + val := before var weeks int fmt.Sscanf(val, "%d", &weeks) d = time.Duration(weeks) * 7 * 24 * time.Hour diff --git a/cmd/search.go b/cmd/search.go index e3eee01..f8e6e79 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -28,7 +28,7 @@ var searchStatusCmd = &cobra.Command{ lastIndexed = indexedAt.Format("2006-01-02 15:04:05") } - data := map[string]interface{}{ + data := map[string]any{ "last_indexed": lastIndexed, "total_entries": len(entries), } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 19f9ee7..57be136 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -63,7 +63,7 @@ func (m *Manager) GenerateKey(namespace string, params any) string { op := "default" if params != nil { t := reflect.TypeOf(params) - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } op = t.String() @@ -114,7 +114,7 @@ func (m *Manager) Set(key string, val any, ttl time.Duration) error { } // Get retrieves cached data. -func (m *Manager) Get(key string, val interface{}) error { +func (m *Manager) Get(key string, val any) error { if config.Get().NoCache { return fmt.Errorf("caching is disabled") } diff --git a/internal/plex/loader.go b/internal/plex/loader.go index 6018d29..d888284 100644 --- a/internal/plex/loader.go +++ b/internal/plex/loader.go @@ -35,7 +35,7 @@ const ( // LoadData performs the initial data fetch and caching. // It sends progress updates through the provided channel. -func LoadData(ctx context.Context, updates chan<- interface{}) { +func LoadData(ctx context.Context, updates chan<- any) { sendUpdate := func(p float64, msg string) { if updates != nil { updates <- ProgressUpdate{Percent: p, Message: msg} diff --git a/internal/presenters/collections.go b/internal/presenters/collections.go index a2ef114..169e21d 100644 --- a/internal/presenters/collections.go +++ b/internal/presenters/collections.go @@ -11,7 +11,7 @@ import ( type CollectionsPresenter struct { SectionID string Collections []components.Metadata - RawData interface{} + RawData any } func (p *CollectionsPresenter) Title() string { @@ -38,7 +38,7 @@ func (p *CollectionsPresenter) Rows() [][]string { return rows } -func (p *CollectionsPresenter) Raw() interface{} { +func (p *CollectionsPresenter) Raw() any { return p.RawData } diff --git a/internal/presenters/history.go b/internal/presenters/history.go index 99c7f1e..61d3a2c 100644 --- a/internal/presenters/history.go +++ b/internal/presenters/history.go @@ -51,7 +51,7 @@ func (p *HistoryPresenter) Rows() [][]string { return rows } -func (p *HistoryPresenter) Raw() interface{} { +func (p *HistoryPresenter) Raw() any { return p.RawData } diff --git a/internal/presenters/library.go b/internal/presenters/library.go index 9129cd7..40bbfb1 100644 --- a/internal/presenters/library.go +++ b/internal/presenters/library.go @@ -58,7 +58,7 @@ func (p *LibraryListPresenter) Rows() [][]string { return rows } -func (p *LibraryListPresenter) Raw() interface{} { +func (p *LibraryListPresenter) Raw() any { return p.Directories } @@ -115,7 +115,7 @@ func (p *LibraryListPresenter) DefaultSort() string { type LibraryItemsPresenter struct { SectionID string Items []GenericMetadata - RawData interface{} + RawData any } func (p *LibraryItemsPresenter) Title() string { @@ -135,7 +135,7 @@ func (p *LibraryItemsPresenter) Rows() [][]string { return rows } -func (p *LibraryItemsPresenter) Raw() interface{} { +func (p *LibraryItemsPresenter) Raw() any { return p.RawData } diff --git a/internal/presenters/presenter.go b/internal/presenters/presenter.go index cf6a6bd..8d6608e 100644 --- a/internal/presenters/presenter.go +++ b/internal/presenters/presenter.go @@ -12,7 +12,7 @@ type Presenter interface { Rows() [][]string // Raw returns the underlying data structure for JSON/YAML output - Raw() interface{} + Raw() any // SortableColumns returns a list of columns that can be sorted SortableColumns() []string diff --git a/internal/presenters/server.go b/internal/presenters/server.go index 8d5f8b2..db1b97f 100644 --- a/internal/presenters/server.go +++ b/internal/presenters/server.go @@ -38,7 +38,7 @@ func (p *ServerListPresenter) Rows() [][]string { return rows } -func (p *ServerListPresenter) Raw() interface{} { +func (p *ServerListPresenter) Raw() any { return p.Devices } @@ -85,7 +85,7 @@ func (p *ServerIdentityPresenter) Rows() [][]string { }} } -func (p *ServerIdentityPresenter) Raw() interface{} { +func (p *ServerIdentityPresenter) Raw() any { return p.Container } @@ -128,7 +128,7 @@ func (p *DevicesPresenter) Rows() [][]string { return rows } -func (p *DevicesPresenter) Raw() interface{} { +func (p *DevicesPresenter) Raw() any { return p.Devices } @@ -168,7 +168,7 @@ type SessionMetadata struct { type SessionsPresenter struct { Sessions []SessionMetadata - RawData interface{} + RawData any } func (p *SessionsPresenter) Title() string { @@ -187,7 +187,7 @@ func (p *SessionsPresenter) Rows() [][]string { return rows } -func (p *SessionsPresenter) Raw() interface{} { +func (p *SessionsPresenter) Raw() any { return p.RawData } diff --git a/internal/presenters/session.go b/internal/presenters/session.go index 1ad2cf9..1e2c481 100644 --- a/internal/presenters/session.go +++ b/internal/presenters/session.go @@ -41,7 +41,7 @@ func (p *SessionListPresenter) Rows() [][]string { return rows } -func (p *SessionListPresenter) Raw() interface{} { +func (p *SessionListPresenter) Raw() any { return p.Metadata } diff --git a/internal/presenters/simple.go b/internal/presenters/simple.go index bf2d591..918e4ac 100644 --- a/internal/presenters/simple.go +++ b/internal/presenters/simple.go @@ -5,13 +5,13 @@ type SimplePresenter struct { T string H []string R [][]string - RawData interface{} + RawData any } func (p SimplePresenter) Title() string { return p.T } func (p SimplePresenter) Headers() []string { return p.H } func (p SimplePresenter) Rows() [][]string { return p.R } -func (p SimplePresenter) Raw() interface{} { return p.RawData } +func (p SimplePresenter) Raw() any { return p.RawData } func (p SimplePresenter) SortableColumns() []string { return []string{} } func (p SimplePresenter) SortBy(column string) bool { return false } func (p SimplePresenter) DefaultSort() string { return "" } diff --git a/internal/presenters/user.go b/internal/presenters/user.go index 0e6f985..8ecb1e9 100644 --- a/internal/presenters/user.go +++ b/internal/presenters/user.go @@ -34,7 +34,7 @@ func (p *HomeUsersPresenter) Rows() [][]string { return rows } -func (p *HomeUsersPresenter) Raw() interface{} { +func (p *HomeUsersPresenter) Raw() any { return p.Users } @@ -91,7 +91,7 @@ func (p *UsersPresenter) Rows() [][]string { return rows } -func (p *UsersPresenter) Raw() interface{} { +func (p *UsersPresenter) Raw() any { return p.Users } diff --git a/internal/tui/splash.go b/internal/tui/splash.go index 5a97a97..83441e4 100644 --- a/internal/tui/splash.go +++ b/internal/tui/splash.go @@ -30,7 +30,7 @@ func ShowStandaloneSplash(minDuration time.Duration, resultChan chan<- plex.Load // 1. Prepare Worker slog.Debug("Splash: Starting data loader") - updates := make(chan interface{}, 10) + updates := make(chan any, 10) go plex.LoadData(context.Background(), updates) // 2. Render Initial Splash @@ -38,16 +38,10 @@ func ShowStandaloneSplash(minDuration time.Duration, resultChan chan<- plex.Load lines := strings.Split(splashArt, "\n") artHeight := len(lines) - startY := (height - artHeight) / 2 - if startY < 1 { - startY = 1 - } + startY := max((height-artHeight)/2, 1) // Calculate horizontal centering - startX := (width - artWidth) / 2 - if startX < 1 { - startX = 1 - } + startX := max((width-artWidth)/2, 1) padding := strings.Repeat(" ", startX) // Print line by line with calculated left padding @@ -101,9 +95,6 @@ func ShowStandaloneSplash(minDuration time.Duration, resultChan chan<- plex.Load } func centerText(text string, width int) string { - padding := (width - len(text)) / 2 - if padding < 0 { - padding = 0 - } + padding := max((width-len(text))/2, 0) return strings.Repeat(" ", padding) + text } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a40e1cd..2ed1c9f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -502,7 +502,7 @@ func (c *Controller) triggerUserSwitch() tea.Cmd { func (c *Controller) fullReload() tea.Cmd { return func() tea.Msg { - updates := make(chan interface{}, 10) + updates := make(chan any, 10) go plex.LoadData(context.Background(), updates) for { diff --git a/internal/tui/view/detail/music_detail_test.go b/internal/tui/view/detail/music_detail_test.go index ce9eccf..f8992bf 100644 --- a/internal/tui/view/detail/music_detail_test.go +++ b/internal/tui/view/detail/music_detail_test.go @@ -114,8 +114,8 @@ func TestAlbumArtworkBesideClickableTracks(t *testing.T) { t.Fatalf("%v: missing artwork or tracks:\n%s", size, view) } for y, line := range strings.Split(view, "\n") { - if x := strings.Index(line, "Zephyr"); x >= 0 { - album.Update(tea.MouseMsg{X: lipgloss.Width(line[:x]), Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if before, _, ok := strings.Cut(line, "Zephyr"); ok { + album.Update(tea.MouseMsg{X: lipgloss.Width(before), Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) } } if album.selected == nil || album.selected.RatingKey != "song" { diff --git a/internal/tui/view/history.go b/internal/tui/view/history.go index 666186b..9720ae0 100644 --- a/internal/tui/view/history.go +++ b/internal/tui/view/history.go @@ -132,7 +132,7 @@ func (t *HistoryTab) fetchNextPage() tea.Msg { var allRows []table.Row var allMeta []operations.ListPlaybackHistoryMetadata - for i := 0; i < 30; i++ { + for range 30 { gte := t.dayCursor.Add(-24 * time.Hour) lte := t.dayCursor diff --git a/internal/tui/widget/config/mouse_test.go b/internal/tui/widget/config/mouse_test.go index 2bf10a0..2c8972c 100644 --- a/internal/tui/widget/config/mouse_test.go +++ b/internal/tui/widget/config/mouse_test.go @@ -20,8 +20,8 @@ func TestLibraryClickBesideEmptyColumn(t *testing.T) { } clicked := false for y, line := range strings.Split(ansi.Strip(view), "\n") { - if x := strings.Index(line, "Mouse QA"); x >= 0 { - m.Update(tea.MouseMsg{X: lipgloss.Width(line[:x]) + 1, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if before, _, ok := strings.Cut(line, "Mouse QA"); ok { + m.Update(tea.MouseMsg{X: lipgloss.Width(before) + 1, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) clicked = true break } diff --git a/internal/tui/widget/iconpicker/nerdfont.go b/internal/tui/widget/iconpicker/nerdfont.go index c960856..7fce381 100644 --- a/internal/tui/widget/iconpicker/nerdfont.go +++ b/internal/tui/widget/iconpicker/nerdfont.go @@ -42,8 +42,8 @@ func (p *NerdFontProvider) normalizeKey(k string) string { } if len(part) > 0 { // Handle underscores if any (e.g. activate_breakpoints) - subParts := strings.Split(part, "_") - for _, sp := range subParts { + subParts := strings.SplitSeq(part, "_") + for sp := range subParts { if len(sp) > 0 { runes := []rune(sp) runes[0] = unicode.ToUpper(runes[0]) diff --git a/internal/tui/widget/poster/grid.go b/internal/tui/widget/poster/grid.go index ecd8830..d2b6dfb 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -115,10 +115,7 @@ func (m *PosterGrid) fetchVisible() tea.Cmd { startRow := m.viewport.YOffset / ui.PosterTotalHeight startIdx := startRow * cols - endIdx := (startRow + rowsVisible + 2) * cols - if endIdx > len(m.Items) { - endIdx = len(m.Items) - } + endIdx := min((startRow+rowsVisible+2)*cols, len(m.Items)) for i := startIdx; i < endIdx; i++ { if m.Items[i].Loading && !m.Items[i].Fetching { diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index df2bd28..ed7cac4 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -187,10 +187,7 @@ func RenderMiniWatched(meta *components.Metadata, theme tint.Tint) string { if filled < 1 && percent > 0 { filled = 1 } - empty := width - filled - if empty < 0 { - empty = 0 - } + empty := max(width-filled, 0) accent := ui.Accent(theme) bar := lipgloss.NewStyle().Foreground(accent).Render(strings.Repeat("━", filled)) + diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 70e9104..430891f 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -175,10 +175,7 @@ func (m *PosterList) View() string { cols := ui.GetLayout().PosterColumns() var itemViews []string - end := m.Offset + cols - if end > len(m.Items) { - end = len(m.Items) - } + end := min(m.Offset+cols, len(m.Items)) for i := m.Offset; i < end; i++ { itemViews = append(itemViews, RenderPosterItem(m.Items[i], i == m.Cursor, m.Active, m.Theme)) diff --git a/internal/tui/widget/visualizer/visualizer_test.go b/internal/tui/widget/visualizer/visualizer_test.go index cf9da08..9b8c5b1 100644 --- a/internal/tui/widget/visualizer/visualizer_test.go +++ b/internal/tui/widget/visualizer/visualizer_test.go @@ -42,7 +42,7 @@ func TestVisualizerFitsTerminalSizes(t *testing.T) { if lipgloss.Height(view) != height+1 { t.Fatalf("%dx%d: unexpected height %d", width, height, lipgloss.Height(view)) } - for _, line := range strings.Split(view, "\n") { + for line := range strings.SplitSeq(view, "\n") { if lipgloss.Width(line) > width { t.Fatalf("%dx%d: line overflows terminal", width, height) } diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index aa625b5..b1c669b 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -68,11 +68,11 @@ func LabelAt(view string, m tea.MouseMsg, label string) bool { return false } line := ansi.Strip(lines[m.Y]) - i := strings.Index(line, label) - if i < 0 { + before, _, ok := strings.Cut(line, label) + if !ok { return false } - x := lipgloss.Width(line[:i]) + x := lipgloss.Width(before) return m.X >= x && m.X < x+lipgloss.Width(label) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 62aa394..4915c5e 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -125,7 +125,7 @@ type OutputData struct { Title string Headers []string Rows [][]string - Raw interface{} // Used for JSON/YAML + Raw any // Used for JSON/YAML } // Print handles the output based on the configured format From ac9ada22621078267cac63dabdba74050aafbe48 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:37:53 -0400 Subject: [PATCH 07/21] fix: focus content after TUI navigation Restore content focus after search results, alphabet jumps, library cycling, and right-click Back so subsequent keys operate on the destination view. Validate regression tests and mouse/keyboard navigation in a separate tmux pane inside Kitty using a local Plex fixture. --- internal/tui/tui.go | 6 +++ internal/tui/tui_test.go | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 internal/tui/tui_test.go diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2ed1c9f..36cd9b6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -228,6 +228,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case ui.JumpToLetterMsg: if target, ok := c.tabManager.ActiveModel().(ui.LetterJumper); ok && target.CanJumpLetters() { + c.focusPane(paneContent) return c, target.JumpToLetter(msg.Letter) } return c, nil @@ -301,6 +302,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.player.TogglePause() } case "tab": + c.focusPane(paneContent) return c, c.tabManager.NextTab() case "x": if c.playerStatus.Active() { @@ -315,6 +317,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "u": return c, c.triggerUserSwitch() case "shift+tab": + c.focusPane(paneContent) return c, c.tabManager.PrevTab() } if c.focus != paneContent { @@ -391,6 +394,7 @@ func (c *Controller) handleJumpToDetail(msg ui.JumpToDetailMsg) tea.Cmd { if libTab, ok := tab.(*view.MediaView); ok { if libTab.GetSectionID() == msg.SectionID { slog.Debug("Controller: Found matching library tab", "idx", i) + c.focusPane(paneContent) initCmd := c.tabManager.SetActive(i) c.returnTabIdx = msg.ReturnTabIdx return tea.Batch(initCmd, libTab.ShowDetail(msg.RatingKey, msg.Type)) @@ -402,6 +406,7 @@ func (c *Controller) handleJumpToDetail(msg ui.JumpToDetailMsg) tea.Cmd { // 2. Fallback: If no section specified OR not found, try the active tab if activeTab, ok := c.tabManager.ActiveModel().(*view.MediaView); ok { slog.Debug("Controller: Applying jump to active library tab") + c.focusPane(paneContent) return activeTab.ShowDetail(msg.RatingKey, msg.Type) } @@ -625,6 +630,7 @@ func (c *Controller) footerText() string { func (c *Controller) handleMouse(mouse tea.MouseMsg) (tea.Model, tea.Cmd) { layout := ui.GetLayout() if ui.RightClick(mouse) { + c.focusPane(paneContent) return c.Update(ui.KeyPress("esc")) } if mouse.X < ui.SidebarWidth && mouse.Y < layout.TotalHeight()-1 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..7c00395 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,88 @@ +package tui + +import ( + "testing" + "time" + + "github.com/LukeHagar/plexgo/models/operations" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/tui/player" + "github.com/ygelfand/plexctl/internal/tui/view" + "github.com/ygelfand/plexctl/internal/ui" + "go.dalton.dog/bubbleup" +) + +func TestRightClickNavigatesBackFromAnotherPane(t *testing.T) { + for _, tt := range []struct { + name string + focus paneFocus + }{ + {name: "sidebar", focus: paneSidebar}, + {name: "player", focus: panePlayer}, + } { + t.Run(tt.name, func(t *testing.T) { + c := focusController() + c.alert = *bubbleup.NewAlertModel(40, true, time.Second) + c.playerStatus = player.PlayerStatus{State: operations.StatePlaying} + c.focusPane(tt.focus) + ui.GetLayout().Update(100, 40, true) + content := c.tabManager.ActiveModel().(*mouseOverlay) + c.Update(tea.MouseMsg{ + X: ui.SidebarTotalWidth + 4, Y: 2, + Button: tea.MouseButtonRight, Action: tea.MouseActionPress, + }) + key, ok := content.last.(tea.KeyMsg) + if !ok || key.Type != tea.KeyEsc { + t.Fatal("right click did not send Back to the content pane") + } + if c.focus != paneContent { + t.Fatal("right click did not restore content focus") + } + }) + } +} + +type letterTab struct { + mouseOverlay + letter string +} + +func (m *letterTab) CanJumpLetters() bool { return true } +func (m *letterTab) JumpToLetter(letter string) tea.Cmd { + m.letter = letter + return nil +} + +func TestNavigationFocusesItsDestination(t *testing.T) { + for _, tt := range []struct { + name string + msg tea.Msg + want int + }{ + {name: "next library", msg: tea.KeyMsg{Type: tea.KeyTab}, want: 1}, + {name: "previous library", msg: tea.KeyMsg{Type: tea.KeyShiftTab}, want: 2}, + {name: "search result", msg: ui.JumpToDetailMsg{SectionID: "music", RatingKey: "500", Type: "artist"}, want: 2}, + {name: "detail in current library", msg: ui.JumpToDetailMsg{RatingKey: "500", Type: "artist"}, want: 2}, + {name: "alphabet destination", msg: ui.JumpToLetterMsg{Letter: "Z"}, want: 0}, + } { + t.Run(tt.name, func(t *testing.T) { + c := focusController() + c.tabManager.tabModels[2] = view.NewMediaView("music", "Music", ui.PlexctlTheme) + if tt.name == "detail in current library" { + c.tabManager.SetActive(2) + } + letter := &letterTab{} + if tt.name == "alphabet destination" { + c.tabManager.tabModels[0] = letter + } + c.focusPane(paneSidebar) + c.Update(tt.msg) + if c.focus != paneContent || c.tabManager.activeTabIdx != tt.want { + t.Fatal("navigation left keyboard focus outside the destination library") + } + if tt.name == "alphabet destination" && letter.letter != "Z" { + t.Fatal("alphabet jump did not reach the destination") + } + }) + } +} From fa8bba16367640bfa44eb0544aad574be3a14bf8 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:37:53 -0400 Subject: [PATCH 08/21] fix: resolve all golangci-lint findings Handle parse, output, registration, and cleanup failures instead of discarding them. Reject malformed or overflowing history durations, preserve existing icons on invalid glyph input, and simplify the static-analysis findings. Keep standard linters enabled with unlimited issue reporting. Exclude only npm dependency directories, which contain unrelated third-party Go examples. Validation: golangci-lint v2.14.0 reports zero issues with default and integration build tags; go test -race -shuffle=on -count=1 -tags=integration ./... passes. --- .golangci.yml | 12 ++ cmd/history.go | 38 ++++--- cmd/history_test.go | 36 ++++++ cmd/play.go | 4 +- cmd/root.go | 17 ++- cmd/tui.go | 9 +- internal/plex/media.go | 12 +- internal/termgraphics/terminal.go | 7 +- internal/termgraphics/terminal_test.go | 20 +++- internal/tools/genicons/main.go | 104 +++++++++++------- internal/tools/genicons/main_test.go | 70 ++++++++++++ internal/tui/artwork/artwork_test.go | 42 +++++-- internal/tui/player/audio_integration_test.go | 6 +- internal/tui/player/terminal.go | 12 +- .../tui/player/terminal_integration_test.go | 12 +- internal/tui/player/window_load.go | 7 +- internal/tui/player/window_load_test.go | 30 ++++- internal/tui/view/detail/music_detail.go | 7 +- internal/tui/view/sessions.go | 7 +- internal/tui/widget/iconpicker/common.go | 2 +- internal/ui/alphabet.go | 8 +- internal/ui/select.go | 5 +- internal/ui/styles.go | 6 +- internal/ui/table.go | 2 +- 24 files changed, 362 insertions(+), 113 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/history_test.go create mode 100644 internal/tools/genicons/main_test.go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c371b8f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,12 @@ +version: "2" + +linters: + default: standard + exclusions: + paths: + # npm dependencies can contain unrelated Go examples. + - (^|/)node_modules/ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/cmd/history.go b/cmd/history.go index b889801..57927b9 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "math" + "strconv" "strings" "time" @@ -25,21 +27,9 @@ var historyCmd = &cobra.Command{ var viewedAt *int64 if since != "" { - d, err := time.ParseDuration(since) + d, err := parseHistoryDuration(since) if err != nil { - if before, ok := strings.CutSuffix(since, "d"); ok { - val := before - var days int - fmt.Sscanf(val, "%d", &days) - d = time.Duration(days) * 24 * time.Hour - } else if before, ok := strings.CutSuffix(since, "w"); ok { - val := before - var weeks int - fmt.Sscanf(val, "%d", &weeks) - d = time.Duration(weeks) * 7 * 24 * time.Hour - } else { - return fmt.Errorf("invalid duration format (use 1h, 1d, 1w): %w", err) - } + return fmt.Errorf("invalid duration format (use 1h, 1d, 1w): %w", err) } ts := time.Now().Add(-d).Unix() viewedAt = &ts @@ -93,6 +83,26 @@ var historyCmd = &cobra.Command{ }), } +func parseHistoryDuration(value string) (time.Duration, error) { + var unit time.Duration + switch { + case strings.HasSuffix(value, "d"): + unit = 24 * time.Hour + case strings.HasSuffix(value, "w"): + unit = 7 * 24 * time.Hour + default: + return time.ParseDuration(value) + } + count, err := strconv.ParseInt(value[:len(value)-1], 10, 64) + if err != nil { + return 0, fmt.Errorf("parse duration %q: %w", value, err) + } + if count > math.MaxInt64/int64(unit) || count < math.MinInt64/int64(unit) { + return 0, fmt.Errorf("duration %q is out of range", value) + } + return time.Duration(count) * unit, nil +} + func init() { rootCmd.AddCommand(historyCmd) historyCmd.Flags().StringVar(&since, "since", "1w", "Time period to show (e.g. 1h, 1d, 1w)") diff --git a/cmd/history_test.go b/cmd/history_test.go new file mode 100644 index 0000000..ad2627d --- /dev/null +++ b/cmd/history_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "testing" + "time" +) + +func TestParseHistoryDuration(t *testing.T) { + for _, tt := range []struct { + name, value string + want time.Duration + wantErr bool + }{ + {name: "hours and minutes", value: "1h30m", want: 90 * time.Minute}, + {name: "days", value: "2d", want: 48 * time.Hour}, + {name: "weeks", value: "3w", want: 21 * 24 * time.Hour}, + {name: "zero", value: "0d"}, + {name: "negative days", value: "-2d", want: -48 * time.Hour}, + {name: "missing count", value: "d", wantErr: true}, + {name: "invalid count", value: "nopew", wantErr: true}, + {name: "trailing text", value: "2junkd", wantErr: true}, + {name: "fractional days", value: "1.5d", wantErr: true}, + {name: "overflow days", value: "106752d", wantErr: true}, + {name: "overflow weeks", value: "15251w", wantErr: true}, + {name: "negative overflow", value: "-106752d", wantErr: true}, + {name: "overflow count", value: "9223372036854775808d", wantErr: true}, + {name: "unknown unit", value: "2y", wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := parseHistoryDuration(tt.value) + if (err != nil) != tt.wantErr || got != tt.want { + t.Fatalf("parseHistoryDuration(%q) = %v, %v; want %v, error=%v", tt.value, got, err, tt.want, tt.wantErr) + } + }) + } +} diff --git a/cmd/play.go b/cmd/play.go index e4210b8..bac3118 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -89,7 +89,9 @@ var playCmd = &cobra.Command{ title = title + "(Trailer)" } pm := player.GetPlayerManager() - fmt.Fprintf(cmd.OutOrStdout(), "Playing %s. Press Ctrl+C to stop.\n", title) + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Playing %s. Press Ctrl+C to stop.\n", title); err != nil { + return fmt.Errorf("write playback status: %w", err) + } // Wait loop for CLI to keep progress reporting alive for pm.VerifyConnection() { diff --git a/cmd/root.go b/cmd/root.go index 08b1aab..bd51bde 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,8 +40,7 @@ var rootCmd = &cobra.Command{ if config.Get().DefaultToTui { return runTUI() } - cmd.Help() - return nil + return cmd.Help() }, } @@ -68,19 +67,19 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.plexctl.yaml)") rootCmd.PersistentFlags().StringVarP(&outputType, "output", "o", "table", "Output format (table, json, json-pretty, yaml, csv, txt)") rootCmd.PersistentFlags().CountP("verbose", "v", "increase verbosity") - viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + cobra.CheckErr(viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))) rootCmd.PersistentFlags().BoolVar(&noCache, "no-cache", false, "Disable caching") - viper.BindPFlag("no_cache", rootCmd.PersistentFlags().Lookup("no-cache")) + cobra.CheckErr(viper.BindPFlag("no_cache", rootCmd.PersistentFlags().Lookup("no-cache"))) rootCmd.PersistentFlags().StringVar(&sortCol, "sort", "", "column to sort by") - viper.BindPFlag("sort", rootCmd.PersistentFlags().Lookup("sort")) + cobra.CheckErr(viper.BindPFlag("sort", rootCmd.PersistentFlags().Lookup("sort"))) rootCmd.PersistentFlags().String("terminal-video", "auto", "Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct)") - viper.BindPFlag("terminal_video", rootCmd.PersistentFlags().Lookup("terminal-video")) + cobra.CheckErr(viper.BindPFlag("terminal_video", rootCmd.PersistentFlags().Lookup("terminal-video"))) playCmd.MarkFlagsMutuallyExclusive("tct", "terminal-video") - rootCmd.RegisterFlagCompletionFunc("terminal-video", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + cobra.CheckErr(rootCmd.RegisterFlagCompletionFunc("terminal-video", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return []string{"auto", "kitty", "sixel", "tct"}, cobra.ShellCompDirectiveNoFileComp - }) + })) } func initConfig() { @@ -107,7 +106,7 @@ func initConfig() { viper.SetEnvPrefix("PLEXCTL") // Explicitly bind env vars that don't have corresponding flags - viper.BindEnv("token") + cobra.CheckErr(viper.BindEnv("token")) if err := viper.ReadInConfig(); err == nil { cfg.ConfigPath = viper.ConfigFileUsed() diff --git a/cmd/tui.go b/cmd/tui.go index 8b41355..3273483 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" "os" @@ -28,7 +29,7 @@ var tuiCmd = &cobra.Command{ }, } -func runTUI() error { +func runTUI() (runErr error) { config.IsTUI = true cfg := config.Get() // Always log TUI sessions to a file for easier debugging @@ -54,7 +55,11 @@ func runTUI() error { if err != nil { return fmt.Errorf("initialize poster graphics: %w", err) } - defer output.Close() + defer func() { + if err := output.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close poster graphics: %w", err)) + } + }() slog.Debug("TUI: Showing standalone splash") tui.ShowStandaloneSplash(1*time.Second, resultChan) diff --git a/internal/plex/media.go b/internal/plex/media.go index 946a857..5a78d6c 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -143,7 +143,11 @@ func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Debug("Close children response", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch children: %s", resp.Status) @@ -199,7 +203,11 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Debug("Close image response", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch image: %s", resp.Status) diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go index 9f13be9..b05f9d7 100644 --- a/internal/termgraphics/terminal.go +++ b/internal/termgraphics/terminal.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "log/slog" "os" "os/exec" "regexp" @@ -199,7 +200,11 @@ func Probe(input *os.File, output io.Writer) Capabilities { if err != nil { return Current() } - defer term.Restore(int(input.Fd()), state) + defer func() { + if err := term.Restore(int(input.Fd()), state); err != nil { + slog.Warn("Restore terminal after graphics probe", "error", err) + } + }() r, err := cancelreader.NewReader(input) if err != nil { return Current() diff --git a/internal/termgraphics/terminal_test.go b/internal/termgraphics/terminal_test.go index a9445e9..7c5dd1a 100644 --- a/internal/termgraphics/terminal_test.go +++ b/internal/termgraphics/terminal_test.go @@ -15,12 +15,20 @@ func TestTmuxMustOwnCurrentTerminal(t *testing.T) { if err != nil { t.Fatal(err) } - defer parent.Close() + t.Cleanup(func() { + if err := parent.Close(); err != nil { + t.Error(err) + } + }) direct, err := os.Create(filepath.Join(dir, "direct-kitty")) if err != nil { t.Fatal(err) } - defer direct.Close() + t.Cleanup(func() { + if err := direct.Close(); err != nil { + t.Error(err) + } + }) if sameTerminal(parent.Name(), direct, direct) { t.Fatal("an inherited TMUX_PANE redirected a new terminal's graphics") } @@ -74,7 +82,9 @@ func TestTmuxPacketsSplitAcrossWrites(t *testing.T) { func TestKittyPlaybackImageOwnership(t *testing.T) { var output bytes.Buffer w := Writer{Output: &output, ImageID: 1073741824} - w.Write([]byte("\x1b_Ga=d;\x1b\\\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\")) + if _, err := w.Write([]byte("\x1b_Ga=d;\x1b\\\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\")); err != nil { + t.Fatal(err) + } want := ansi.TmuxPassthrough(DeleteImage(1073741824)) + ansi.TmuxPassthrough("\x1b_Ga=T,f=24,s=1,v=1,i=1073741824;AAAA\x1b\\") if output.String() != want { t.Fatal("video could overwrite or delete other applications' images") @@ -97,7 +107,9 @@ func TestKittyFramePlacement(t *testing.T) { if output.String() != input { t.Fatalf("split %d: direct graphics were altered", split) } - w.Write([]byte("\x1b[0;0H\x1b_Ga=T,f=24,s=400,v=225;AAAA\x1b\\")) + if _, err := w.Write([]byte("\x1b[0;0H\x1b_Ga=T,f=24,s=400,v=225;AAAA\x1b\\")); err != nil { + t.Fatal(err) + } if w.Frame() != (Frame{Width: 400, Height: 225}) { t.Fatal("frame was not updated after resize") } diff --git a/internal/tools/genicons/main.go b/internal/tools/genicons/main.go index 24b264d..3834e71 100644 --- a/internal/tools/genicons/main.go +++ b/internal/tools/genicons/main.go @@ -2,12 +2,15 @@ package main import ( "encoding/json" + "errors" "fmt" "io" "net/http" "os" "sort" + "strconv" "strings" + "unicode/utf8" "golang.org/x/net/html" ) @@ -19,43 +22,45 @@ const ( func main() { fmt.Println("Fetching Nerd Fonts cheat sheet...") + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() (runErr error) { resp, err := http.Get(cheatSheetURL) if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching: %v\n", err) - os.Exit(1) + return fmt.Errorf("fetch cheat sheet: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + runErr = errors.Join(runErr, fmt.Errorf("close cheat sheet response: %w", err)) + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch cheat sheet: %s", resp.Status) } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - fmt.Fprintf(os.Stderr, "Error reading body: %v\n", err) - os.Exit(1) + return fmt.Errorf("read cheat sheet: %w", err) } - glpyhs := findGlyphScript(string(body)) - if glpyhs == "" { - fmt.Fprintln(os.Stderr, "Could not find Nerd Font glyphs object in HTML") - os.Exit(1) + script, err := findGlyphScript(string(body)) + if err != nil { + return err } - - icons := parseGlyphs(glpyhs) - - generateGoFile(icons) -} - -func generateGoFile(glyphs map[string]string) { - f, err := os.Create(outputFile) + icons, err := parseGlyphs(script) if err != nil { - fmt.Fprintf(os.Stderr, "Error creating file: %v\n", err) - os.Exit(1) + return err } - defer f.Close() + return generateGoFile(outputFile, icons) +} - fmt.Fprintln(f, "package nerdfonts") - fmt.Fprintln(f, "") - fmt.Fprintln(f, "// Code generated by internal/tools/genicons. DO NOT EDIT.") - fmt.Fprintln(f, "") - fmt.Fprintln(f, "var Icons = map[string]string{") +func generateGoFile(path string, glyphs map[string]string) error { + var source strings.Builder + source.WriteString("package nerdfonts\n\n// Code generated by internal/tools/genicons. DO NOT EDIT.\n\nvar Icons = map[string]string{\n") keys := make([]string, 0, len(glyphs)) for k := range glyphs { @@ -64,18 +69,24 @@ func generateGoFile(glyphs map[string]string) { sort.Strings(keys) for _, k := range keys { - hex := glyphs[k] - char := hexToChar(hex) - fmt.Fprintf(f, "\t%q: %q,\n", k, char) + char, err := hexToChar(glyphs[k]) + if err != nil { + return fmt.Errorf("convert glyph %q: %w", k, err) + } + fmt.Fprintf(&source, "\t%q: %q,\n", k, char) } - fmt.Fprintln(f, "}") + source.WriteString("}\n") + if err := os.WriteFile(path, []byte(source.String()), 0o644); err != nil { + return fmt.Errorf("write icons: %w", err) + } + return nil } -func findGlyphScript(htmlData string) string { +func findGlyphScript(htmlData string) (string, error) { doc, err := html.Parse(strings.NewReader(htmlData)) if err != nil { - panic(err) + return "", fmt.Errorf("parse cheat sheet HTML: %w", err) } var scriptContent string @@ -92,28 +103,31 @@ func findGlyphScript(htmlData string) string { } } f(doc) - return scriptContent + if scriptContent == "" { + return "", errors.New("could not find Nerd Font glyphs object in HTML") + } + return scriptContent, nil } -func parseGlyphs(js string) map[string]string { +func parseGlyphs(js string) (map[string]string, error) { start := strings.Index(js, "{") end := strings.LastIndex(js, "}") - if start == -1 || end == -1 { - panic("could not find object literal") + if start == -1 || end < start { + return nil, errors.New("could not find glyph object literal") } obj := js[start : end+1] obj = removeTrailingComma(obj) var glyphs map[string]string if err := json.Unmarshal([]byte(obj), &glyphs); err != nil { - panic(err) + return nil, fmt.Errorf("parse glyph object: %w", err) } - return glyphs + return glyphs, nil } func removeTrailingComma(js string) string { lastBrace := strings.LastIndex(js, "}") if lastBrace == -1 { - panic("no closing brace found") + return js } i := lastBrace - 1 @@ -128,8 +142,14 @@ func removeTrailingComma(js string) string { return js } -func hexToChar(hex string) string { - var r rune - fmt.Sscanf(hex, "%x", &r) - return string(r) +func hexToChar(value string) (string, error) { + codepoint, err := strconv.ParseUint(value, 16, 32) + if err != nil { + return "", fmt.Errorf("parse code point %q: %w", value, err) + } + r := rune(codepoint) + if !utf8.ValidRune(r) { + return "", fmt.Errorf("invalid Unicode code point %q", value) + } + return string(r), nil } diff --git a/internal/tools/genicons/main_test.go b/internal/tools/genicons/main_test.go new file mode 100644 index 0000000..466a8f2 --- /dev/null +++ b/internal/tools/genicons/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateGoFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "icons.go") + script, err := findGlyphScript(``) + if err != nil { + t.Fatal(err) + } + glyphs, err := parseGlyphs(script) + if err != nil { + t.Fatal(err) + } + if err := generateGoFile(path, glyphs); err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := parser.ParseFile(token.NewFileSet(), path, source, parser.AllErrors); err != nil { + t.Fatalf("generated invalid Go: %v", err) + } + text := string(source) + if !strings.Contains(text, `"album": "\uf02f"`) || !strings.Contains(text, `"music": "\uf001"`) || strings.Index(text, `"album"`) > strings.Index(text, `"music"`) { + t.Fatalf("incorrect or unsorted glyphs: %s", text) + } + if err := generateGoFile(t.TempDir(), glyphs); err == nil { + t.Fatal("writing to a directory succeeded") + } +} + +func TestInvalidGlyphDoesNotOverwriteFile(t *testing.T) { + for _, value := range []string{"", "nope", "f001garbage", "d800", "110000", "ffffffff"} { + t.Run(value, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "icons.go") + if err := os.WriteFile(path, []byte("existing icons"), 0o600); err != nil { + t.Fatal(err) + } + if err := generateGoFile(path, map[string]string{"bad": value}); err == nil { + t.Fatal("accepted an invalid code point") + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "existing icons" { + t.Fatalf("existing file changed: %q, %v", data, err) + } + }) + } +} + +func TestInvalidGlyphScript(t *testing.T) { + if _, err := findGlyphScript("No glyphs"); err == nil { + t.Fatal("missing glyphs accepted") + } + for _, script := range []string{"", "const glyphs = }{", "const glyphs = {invalid};"} { + t.Run(script, func(t *testing.T) { + if _, err := parseGlyphs(script); err == nil { + t.Fatal("invalid glyph object accepted") + } + }) + } +} diff --git a/internal/tui/artwork/artwork_test.go b/internal/tui/artwork/artwork_test.go index 7818f75..1e70a4c 100644 --- a/internal/tui/artwork/artwork_test.go +++ b/internal/tui/artwork/artwork_test.go @@ -23,12 +23,20 @@ func TestNativePosterLifecycle(t *testing.T) { if err != nil { t.Fatal(err) } - defer f.Close() + t.Cleanup(func() { + if err := f.Close(); err != nil { + t.Error(err) + } + }) w, err := Open(f, true) if err != nil { t.Fatal(err) } - defer w.Close() + t.Cleanup(func() { + if err := w.Close(); err != nil { + t.Error(err) + } + }) view, err := Render("test", image.NewRGBA(image.Rect(0, 0, 600, 900)), 15, 10) if err != nil { t.Fatal(err) @@ -62,17 +70,29 @@ func TestNativePosterLifecycle(t *testing.T) { if dimensions.Width != 512 || dimensions.Height != 768 { t.Fatalf("poster lost image detail: %+v", dimensions) } - before, _ := f.Stat() - w.Write([]byte(frame)) - after, _ := f.Stat() - if after.Size()-before.Size() != int64(len(frame)) { + size := func() int64 { + t.Helper() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + return info.Size() + } + before := size() + if _, err := w.Write([]byte(frame)); err != nil { + t.Fatal(err) + } + if size()-before != int64(len(frame)) { t.Fatal("poster uploaded again without a change") } - w.Write([]byte("\x1b[?1049l")) - before, _ = f.Stat() - w.Write([]byte(frame)) - after, _ = f.Stat() - if after.Size()-before.Size() <= int64(len(frame)) { + if _, err := w.Write([]byte("\x1b[?1049l")); err != nil { + t.Fatal(err) + } + before = size() + if _, err := w.Write([]byte(frame)); err != nil { + t.Fatal(err) + } + if size()-before <= int64(len(frame)) { t.Fatal("poster not reuploaded after returning from playback") } dir := posters.dir diff --git a/internal/tui/player/audio_integration_test.go b/internal/tui/player/audio_integration_test.go index fadfe6d..0f08874 100644 --- a/internal/tui/player/audio_integration_test.go +++ b/internal/tui/player/audio_integration_test.go @@ -70,7 +70,11 @@ func TestAudioPlaybackAndSpectrum(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(dir) + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Error(err) + } + }) media := testAudio(t, dir) socket := filepath.Join(dir, "ipc") cmd := exec.Command(mpv, "--no-config", "--ao=null", "--idle=yes", "--vid=no", diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index 573c1ba..fcef778 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log/slog" "math/rand/v2" "net" "os" @@ -131,7 +132,11 @@ func (p *terminalPlayback) Run() error { if err != nil { return fmt.Errorf("create terminal player socket directory: %w", err) } - defer os.RemoveAll(dir) + defer func() { + if err := os.RemoveAll(dir); err != nil { + slog.Warn("Remove terminal player socket directory", "error", err) + } + }() socket := filepath.Join(dir, "ipc") if runtime.GOOS == "windows" { socket = `\\.\pipe\` + filepath.Base(dir) @@ -216,7 +221,6 @@ func (p *terminalPlayback) Run() error { if err != nil { return err } - defer conn.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() events := make(chan terminalEvent, 64) @@ -227,7 +231,9 @@ func (p *terminalPlayback) Run() error { }() defer func() { cancel() - _ = conn.Close() + if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + slog.Debug("Close terminal player connection", "error", err) + } <-readerDone }() diff --git a/internal/tui/player/terminal_integration_test.go b/internal/tui/player/terminal_integration_test.go index a7b147e..f44ee66 100644 --- a/internal/tui/player/terminal_integration_test.go +++ b/internal/tui/player/terminal_integration_test.go @@ -192,12 +192,20 @@ func TestTerminalMPVHelper(t *testing.T) { if err != nil { os.Exit(12) } - defer listener.Close() + t.Cleanup(func() { + if err := listener.Close(); err != nil { + t.Error(err) + } + }) conn, err := listener.Accept() if err != nil { os.Exit(13) } - defer conn.Close() + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + }) decoder := json.NewDecoder(conn) encoder := json.NewEncoder(conn) for { diff --git a/internal/tui/player/window_load.go b/internal/tui/player/window_load.go index 48ca0b6..d54b06b 100644 --- a/internal/tui/player/window_load.go +++ b/internal/tui/player/window_load.go @@ -3,6 +3,7 @@ package player import ( "encoding/json" "fmt" + "log/slog" "time" ) @@ -14,7 +15,11 @@ func loadWindowMedia(socket, url string, startOffset int64) error { if err != nil { return fmt.Errorf("connect to mpv for playback: %w", err) } - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + slog.Debug("Close playback command connection", "error", err) + } + }() if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { return err } diff --git a/internal/tui/player/window_load_test.go b/internal/tui/player/window_load_test.go index 19eb9d9..d35521a 100644 --- a/internal/tui/player/window_load_test.go +++ b/internal/tui/player/window_load_test.go @@ -18,21 +18,34 @@ func TestWindowLoadNamedArguments(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(dir) + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Error(err) + } + }) path := filepath.Join(dir, "ipc") listener, err := net.Listen("unix", path) if err != nil { t.Fatal(err) } - defer listener.Close() + t.Cleanup(func() { + if err := listener.Close(); err != nil { + t.Error(err) + } + }) done := make(chan error, 1) go func() { + defer close(done) conn, err := listener.Accept() if err != nil { done <- err return } - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + done <- err + } + }() var request struct { Command map[string]any `json:"command"` } @@ -47,14 +60,19 @@ func TestWindowLoadNamedArguments(t *testing.T) { return } encoder := json.NewEncoder(conn) - _ = encoder.Encode(map[string]any{"event": "start-file"}) + if err := encoder.Encode(map[string]any{"event": "start-file"}); err != nil { + done <- err + return + } done <- encoder.Encode(map[string]any{"request_id": 1, "error": result}) }() if err := loadWindowMedia(path, "https://plex.invalid/music.flac", 12345); (err != nil) != (result != "success") { t.Errorf("loadWindowMedia returned %v for %s", err, result) } - if err := <-done; err != nil { - t.Fatal(err) + for err := range done { + if err != nil { + t.Error(err) + } } }) } diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go index 0ebe7ce..37cd3f0 100644 --- a/internal/tui/view/detail/music_detail.go +++ b/internal/tui/view/detail/music_detail.go @@ -254,10 +254,11 @@ func (v *MusicDetailView) View() string { v.listRect = ui.Rect{} v.rail = ui.AlphabetRail{} footer := "[p] Play | [v] Player | [esc] Back" - if v.Metadata.Type == "track" { + switch v.Metadata.Type { + case "track": header += "\n\n" + renderBadges(v.Metadata, v.Theme) header = v.RenderPosterAndInfo(header) - } else if v.Metadata.Type == "artist" { + case "artist": rect := ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} rect.Height = max(1, ui.GetLayout().ContentHeight()-rect.Y-3) v.rail = ui.NewAlphabetRail(2+width, rect.Y, rect.Height) @@ -274,7 +275,7 @@ func (v *MusicDetailView) View() string { lipgloss.NewStyle().Width(rect.Width).MaxWidth(rect.Width).Render(v.albums.View()), v.rail.View(active, v.Theme)) footer = "[g] A–Z | [p] Play | [v] Player | [esc] Back" - } else { + default: v.listRect = ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-3) v.rail = ui.NewAlphabetRail(2+width, v.listRect.Y, v.listRect.Height) diff --git a/internal/tui/view/sessions.go b/internal/tui/view/sessions.go index e05ff3d..a789a61 100644 --- a/internal/tui/view/sessions.go +++ b/internal/tui/view/sessions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "github.com/LukeHagar/plexgo/models/operations" "github.com/charmbracelet/bubbles/table" @@ -73,7 +74,11 @@ func (t *SessionsTab) fetchSessions() tea.Msg { return err } - defer res.RawResponse.Body.Close() + defer func() { + if err := res.RawResponse.Body.Close(); err != nil { + slog.Debug("Close sessions response", "error", err) + } + }() body, err := io.ReadAll(res.RawResponse.Body) if err != nil { return err diff --git a/internal/tui/widget/iconpicker/common.go b/internal/tui/widget/iconpicker/common.go index 97b456f..f648466 100644 --- a/internal/tui/widget/iconpicker/common.go +++ b/internal/tui/widget/iconpicker/common.go @@ -319,7 +319,7 @@ func (p *IconPicker) searchView() string { prefix = "> " b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Render(fmt.Sprintf("%s %s %s", prefix, e.Char, e.Name)) + "\n") } else { - b.WriteString(fmt.Sprintf("%s %s %s\n", prefix, e.Char, e.Name)) + fmt.Fprintf(&b, "%s %s %s\n", prefix, e.Char, e.Name) } } } diff --git a/internal/ui/alphabet.go b/internal/ui/alphabet.go index 8f55308..4952633 100644 --- a/internal/ui/alphabet.go +++ b/internal/ui/alphabet.go @@ -87,11 +87,9 @@ func AlphabetTitle(meta components.Metadata) string { } func AlphabetLetter(meta components.Metadata) string { - for _, r := range AlphabetTitle(meta) { - if r >= 'A' && r <= 'Z' { - return string(r) - } - return "#" + title := AlphabetTitle(meta) + if len(title) > 0 && title[0] >= 'A' && title[0] <= 'Z' { + return title[:1] } return "#" } diff --git a/internal/ui/select.go b/internal/ui/select.go index 91e255c..7605259 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "io" + "log/slog" "strings" "github.com/charmbracelet/bubbles/list" @@ -43,7 +44,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } } - fmt.Fprint(w, fn(str)) + if _, err := fmt.Fprint(w, fn(str)); err != nil { + slog.Warn("Render menu option", "error", err) + } } type model struct { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 4915c5e..2c73ece 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -235,7 +235,9 @@ func (d OutputData) printTable() error { } } - table.Bulk(sanitizedRows) + if err := table.Bulk(sanitizedRows); err != nil { + return fmt.Errorf("populate table: %w", err) + } return table.Render() } @@ -248,7 +250,7 @@ func RenderSummary(title string, items []struct{ Label, Value string }) { var b strings.Builder for _, item := range items { - b.WriteString(fmt.Sprintf("%s %s\n", LabelStyle(theme).Render(item.Label+":"), ValueStyle(theme).Render(item.Value))) + fmt.Fprintf(&b, "%s %s\n", LabelStyle(theme).Render(item.Label+":"), ValueStyle(theme).Render(item.Value)) } fmt.Println(b.String()) } diff --git a/internal/ui/table.go b/internal/ui/table.go index 1a26351..0345467 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -38,7 +38,7 @@ type Table struct { func (t *Table) syncScroll() { if t.Cursor() >= len(t.Rows()) { - t.Model.SetCursor(max(0, len(t.Rows())-1)) + t.SetCursor(max(0, len(t.Rows())-1)) } height := max(1, t.Height()) t.first = min(t.first, max(0, len(t.Rows())-height)) From 5923d14de798ee4d4e20300ea49e6d029921be30 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:37:53 -0400 Subject: [PATCH 09/21] chore(deps): upgrade to Go 1.27.2 and patched text normalization Upgrade Go 1.25.6 -> 1.27.2 and document the macOS 13 minimum and development checks. Tidy consolidates existing direct and indirect require blocks. Upgrade golang.org/x/text v0.37.0 -> v0.43.0, the latest stable release resolved 2026-10-10 through the Go module proxy. govulncheck reproduced GO-2026-5970 on untouched HEAD: AlphabetTitle calls the affected normalization API. Invalid UTF-8 can trigger an infinite loop in old releases; this removes the affected dependency. Canonical JSON input normally replaces invalid UTF-8 already. The module graph requires these accompanying upgrades: - x/crypto v0.51.0 -> v0.57.0 - x/mod v0.35.0 -> v0.41.0 - x/net v0.55.0 -> v0.59.0 - x/sync v0.20.0 -> v0.24.0 - x/sys v0.45.0 -> v0.48.0 - x/term v0.43.0 -> v0.46.0 - x/tools v0.44.0 -> v0.51.0 Sources: https://go.dev/doc/devel/release#go1.27.2 https://pkg.go.dev/vuln/GO-2026-5970 https://github.com/golang/text/compare/v0.37.0...v0.43.0 Validation: module verification/tidy, race-enabled integration tests, lint, five GoReleaser platform builds, and zero reachable govulncheck findings. Rollback: restore Go 1.25.6 and the old module versions listed above. --- README.md | 19 ++++++++++++++++- go.mod | 63 ++++++++++++++++++++++++++----------------------------- go.sum | 32 ++++++++++++++-------------- 3 files changed, 64 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e0760e7..536f7e8 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,29 @@ chmod +x plexctl sudo mv plexctl /usr/local/bin/ ``` -Alternatively, if you have Go installed: +Alternatively, with Go **1.27.2 or newer** installed: ```bash go install github.com/ygelfand/plexctl@latest ``` +Builds made with Go 1.27 require macOS 13 Ventura or newer on macOS. + +## Development checks + +Use Go 1.27.2 or newer and golangci-lint 2.14.0, built with the same Go version: + +```bash +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 +go build ./... +go test -race -tags=integration ./... +golangci-lint run +golangci-lint run --build-tags integration +``` + +The audio integration test uses a local mpv installation and skips when mpv is unavailable. +Pull requests run these checks and `govulncheck` in GitHub Actions. + ## Features - **Interactive TUI**: Navigate your Plex library with ease. diff --git a/go.mod b/go.mod index 856d1e2..4530ffb 100644 --- a/go.mod +++ b/go.mod @@ -1,85 +1,83 @@ module github.com/ygelfand/plexctl -go 1.25.6 +go 1.27.2 require ( github.com/BrenekH/go-plexauth v0.0.2 + github.com/LukeHagar/plexgo v0.28.2 github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.4 + github.com/charmbracelet/x/term v0.2.2 github.com/kyokomi/emoji/v2 v2.2.13 github.com/lovelydeng/gomoji v0.0.0-20221120141925-ea446dc92ac0 + github.com/lrstanley/bubbletint v1.0.0 + github.com/muesli/cancelreader v0.2.2 github.com/olekukonko/tablewriter v1.1.3 github.com/peterbourgon/diskv v2.0.1+incompatible github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/sahilm/fuzzy v0.1.1 github.com/saran13raj/go-pixels v0.0.0-20250629121333-58b240a3ae51 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 go.dalton.dog/bubbleup v1.3.0 - golang.org/x/net v0.55.0 - golang.org/x/term v0.43.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/clipperhouse/displaywidth v0.7.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect - github.com/lovelydeng/uniseg v0.0.0-20221120141218-19f3806b842a // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.1.0 // indirect - github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect golang.org/x/image v0.38.0 - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.43.0 gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/LukeHagar/plexgo v0.28.2 github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect - github.com/charmbracelet/x/ansi v0.11.4 github.com/charmbracelet/x/cellbuf v0.0.14 // indirect - github.com/charmbracelet/x/term v0.2.2 + github.com/clipperhouse/displaywidth v0.7.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/doordash-oss/oapi-codegen-dd/v3 v3.66.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lrstanley/bubbletint v1.0.0 + github.com/lovelydeng/uniseg v0.0.0-20221120141218-19f3806b842a // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/pb33f/jsonpath v0.7.0 // indirect github.com/pb33f/libopenapi v0.31.2 // indirect github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sahilm/fuzzy v0.1.1 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -88,12 +86,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 - golang.org/x/text v0.37.0 - golang.org/x/tools v0.44.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/tools v0.51.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) tool github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 2496873..0c66634 100644 --- a/go.sum +++ b/go.sum @@ -174,29 +174,29 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From 5daebe80d394bf162b9b463394a861a9fdbcf78c Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:37:53 -0400 Subject: [PATCH 10/21] chore(deps): update and SHA-pin GitHub Actions Resolve approved stable major upgrades from upstream releases on 2026-10-10; verify each full SHA against its upstream release tag with git ls-remote: - actions/checkout v4 (docs), v5 (release) -> v7.0.1 - actions/setup-node v4 -> v7.1.0 - actions/setup-go v6 -> v7.0.0 - actions/configure-pages v4 -> v6.0.0 - actions/upload-pages-artifact v3 -> v5.0.0 - actions/deploy-pages v4 -> v5.0.1 - goreleaser/goreleaser-action v6 -> v7.2.3 The Node 24 action runtimes are compatible with hosted Ubuntu runners. Keep explicit npm caching and preserve hidden Pages export files across the upload action's changed default. Select the release compiler from go.mod. Security verdict: defensive upgrades and immutable pins. The checkout fork-head restriction does not affect our push/dispatch release and Pages workflows; there are no privileged PR triggers. Disable persisted checkout credentials and grant Pages/OIDC and release write permissions only to publishing jobs. Add read-only PR checks for race-enabled tests, lint, module integrity, builds, and govulncheck with exact analysis-tool versions compiled using project Go. Validation: actionlint and YAML/pin checks; Node 24 docs build (47 pages) and Pages archive; GoReleaser v2.18.3 config check and five snapshot build targets. Workflows were validated locally; no remote workflow was triggered. Rollback: restore the action majors listed above, Go 1.25 release selection, and remove the new CI workflow. --- .github/workflows/ci.yaml | 46 ++++++++++++++++++++++++++++++++++ .github/workflows/docs.yaml | 20 +++++++++------ .github/workflows/release.yaml | 13 ++++++---- 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3e9ed07 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,46 @@ +name: Go checks + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: go-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Verify modules + run: | + go mod verify + go mod tidy -diff + - name: Build + run: go build ./... + - name: Test with race detector + run: go test -race -shuffle=on -count=1 -tags=integration ./... + - name: Install analysis tools + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 + go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 + - name: Lint + run: | + golangci-lint run + golangci-lint run --build-tags integration + - name: Check vulnerabilities + run: govulncheck ./... diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index ba19087..383fc5f 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -11,8 +11,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: "pages" @@ -20,22 +18,26 @@ concurrency: jobs: build: + permissions: + contents: read + pages: read runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "24" cache: "npm" cache-dependency-path: docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Install dependencies run: npm ci @@ -46,11 +48,15 @@ jobs: working-directory: docs - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./docs/out + include-hidden-files: true deploy: + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -59,4 +65,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 711c456..91a855f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,22 +6,25 @@ on: - "v*" permissions: - contents: write + contents: read jobs: goreleaser: + permissions: + contents: write runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.25" + go-version-file: go.mod - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: latest From 7fb5ee39b3faa547d367e917936bb3d380c0306a Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:42:45 -0400 Subject: [PATCH 11/21] chore(deps): refresh docs dependencies from upstream Reconcile upstream docs Dependabot PRs #33 and #35-40 on current fork HEAD. Regenerate the npm lockfile instead of copying the upstream diff. Keep the existing package.json semver ranges and lockfile format. Resolved targets (official npm registry checked 2026-10-10): - next and @next/env: 16.3.0 -> 16.4.0 - @next/eslint-plugin-next and eslint-config-next: 16.1.6 -> 16.4.0 - sharp and platform binaries: 0.35.3 -> 0.35.5 - sharp-libvips platform binaries: 1.3.2 -> 1.3.4 - nanoid: 3.3.17 -> 3.3.20 - js-yaml: 4.3.1 -> 4.3.2 - dompurify: 3.4.13 -> 3.4.16 - @humanfs/node: 0.16.7 -> 0.16.8 - @humanfs/core: 0.19.1 -> 0.19.2; add required @humanfs/types 0.15.0 - brace-expansion: 1.1.18 -> 1.1.21 and 5.0.9 -> 5.0.12 - remove the obsolete brace-expansion 2.1.4 copy as the updated TypeScript ESLint tree reuses minimatch 10 and brace-expansion 5 Use newer compatible targets than upstream for Next.js (16.3.4), sharp (0.35.4), and nanoid (3.3.18), including subsequent security fixes. Align Next.js lint companions with its runtime; this refreshes their TypeScript ESLint family 8.54.0 -> 8.71.1, React Hooks plugin 7.0.1 -> 7.1.1, Babel 7.29.x, and browser compatibility data. The parser now requires a nested eslint-visitor-keys 5.0.1; Node 24 satisfies its engine requirement. @swc/helpers moves 0.5.15 -> 0.5.23 with Next.js. No direct major upgrades or release-channel changes. Security verdict: the lockfile contained affected dependency versions; these upgrades clear audit findings for all seven target groups and Babel. Published docs use output: export and unoptimized images, so the Next.js server/cache/image-optimizer advisories do not affect that static deployment: defensive bump. No project usage of IN_PLACE sanitizer hooks, humanfs copyAll, nanoid custom generators, or untrusted YAML/glob input was found. Audit improves from 25 to 17 findings (critical 1 -> 0), with no new findings. The remaining advisories predate this update. Validation: npm ci on Node 24.21.0/npm 11.19.0; npm ls for every target; source ESLint (existing no-img-element warning); production build with all 47 exported routes unchanged and referenced assets present; browser homepage, TUI Guide and CLI play navigation with no console errors; git diff --check. Default ESLint already includes generated out/ files and fails on the original lockfile; source lint ignores out/**. Upstream PRs are already merged; their states were not changed. Rollback: revert this commit to restore the previous versions above, then run npm ci in docs. --- docs/package-lock.json | 834 +++++++++++++++++++++-------------------- 1 file changed, 418 insertions(+), 416 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 10b704b..dfa9c26 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -38,13 +38,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -53,9 +53,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -63,21 +63,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -104,14 +104,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -121,14 +121,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -148,9 +148,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -158,29 +158,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -190,9 +190,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -200,9 +200,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -210,9 +210,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -220,27 +220,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -250,33 +250,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -284,14 +284,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -595,29 +595,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -674,9 +688,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -692,13 +706,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -714,20 +728,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -737,9 +751,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -753,9 +767,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -769,9 +783,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -788,9 +802,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -807,9 +821,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -826,9 +840,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -845,9 +859,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -864,9 +878,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -883,9 +897,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -902,9 +916,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -921,9 +935,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -942,13 +956,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -967,13 +981,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -992,13 +1006,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -1017,13 +1031,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1042,13 +1056,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1067,13 +1081,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1092,13 +1106,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1117,17 +1131,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1137,16 +1151,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1156,9 +1170,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1175,9 +1189,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1194,9 +1208,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1245,9 +1259,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1588,18 +1602,19 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz", + "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, @@ -1621,9 +1636,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", "cpu": [ "arm64" ], @@ -1637,9 +1652,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", "cpu": [ "x64" ], @@ -1653,9 +1668,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", "cpu": [ "arm64" ], @@ -1672,9 +1687,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", "cpu": [ "arm64" ], @@ -1691,9 +1706,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", "cpu": [ "x64" ], @@ -1710,9 +1725,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", "cpu": [ "x64" ], @@ -1729,9 +1744,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", "cpu": [ "arm64" ], @@ -1745,9 +1760,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", "cpu": [ "x64" ], @@ -1991,9 +2006,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -2427,20 +2442,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2450,15 +2465,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2466,16 +2481,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2486,19 +2501,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2509,18 +2524,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2531,9 +2546,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -2544,21 +2559,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2568,14 +2583,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -2587,21 +2602,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2611,46 +2626,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2660,19 +2649,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2682,6 +2671,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript/vfs": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.2.tgz", @@ -3315,9 +3317,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", - "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3339,9 +3341,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3362,9 +3364,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3382,11 +3384,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.20", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3456,9 +3458,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "funding": [ { "type": "opencollective", @@ -4376,9 +4378,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4400,9 +4402,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.422", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", - "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, @@ -4727,21 +4729,21 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz", + "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.6", + "@next/eslint-plugin-next": "16.4.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -4995,9 +4997,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5011,7 +5013,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { @@ -6756,9 +6758,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -8207,9 +8209,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8253,9 +8255,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -8303,13 +8305,13 @@ } }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.4.0", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -8322,15 +8324,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8455,9 +8457,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, "license": "MIT", "engines": { @@ -9774,9 +9776,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -9791,31 +9793,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -10386,9 +10388,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -10574,16 +10576,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10593,8 +10595,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/ufo": { @@ -10828,9 +10830,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", - "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { From 5152b50c1fc8f61a4e57236fe18727fe248d8f13 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:52:43 -0400 Subject: [PATCH 12/21] fix: keep poster navigation aligned with visible rows Lowercase h was intercepted by the global Home shortcut before reaching poster lists and grids. Use uppercase H for Home, restore content focus when it is invoked, and update help and the TUI guide. Moving between Home rows now selects the poster in the same visible column using each row's own horizontal offset, instead of restoring its previous cursor. Short rows clamp to the nearest available poster. Resizing a horizontal row also keeps its selected poster visible. Regression tests reproduced the intercepted h key, stale row selections, and selection disappearing after resize before the fix. Cover both key families, independent offsets, short rows, edges, and the Home shortcut. Validation: race/shuffled integration suite; standard and integration golangci-lint (0 issues); Go build; 47-page docs build; 17 selection checks and the Home shortcut in a separate tmux pane using a local Plex fixture. Rebuild and install the binary for the corrected key bindings. --- docs/content/tui.mdx | 4 +- internal/tui/tui.go | 5 +- internal/tui/tui_test.go | 61 ++++++++++++++++ internal/tui/view/home.go | 42 ++++++----- internal/tui/view/home_test.go | 92 +++++++++++++++++++++++++ internal/tui/widget/poster/list.go | 2 +- internal/tui/widget/poster/list_test.go | 21 ++++++ 7 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 internal/tui/view/home_test.go create mode 100644 internal/tui/widget/poster/list_test.go diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 5bad625..e048c05 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -5,7 +5,7 @@ The plexctl Interactive Interface (TUI) provides a rich, full-screen experience ## Navigation - **`Tab` / `Shift+Tab`**: Cycle through main tabs (Home, Libraries, History, etc.) -- **`j` / `k` (or Arrows)**: Move cursor up and down. +- **`j` / `k` (or Down/Up)**: Move down/up. Between Home poster rows, keep the same visible column, or select the nearest poster in a shorter row. - **`h` / `l` (or Arrows)**: Move through horizontal lists or scroll columns. - **`Enter`**: Select an item or view details. - **`V`** (uppercase): Switch a library between list and poster views. @@ -41,7 +41,7 @@ mpv's Space, Right/Left, and q bindings, including any custom bindings you set. - **`ctrl+s`**: Open global settings (theme/icon type). - **`r`**: Refresh current view / trigger reindex (on search page). - **`x`**: Stop current playback. -- **`h`**: Jump directly to Home tab. +- **`H`** (uppercase): Jump directly to Home tab. Lowercase **`h`** moves left within the current view. - **`?`**: Show help overlay. **Ctrl+H** reaches the left navigation; **Ctrl+L** returns to the library. diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 36cd9b6..b4d86fa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -265,7 +265,8 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.navigator.Push(settings.NewSettingsOverlayModel(c.theme)) case "L": return c, c.navigator.Push(tuiconfig.NewConfigOverlayModel(c.data.Libraries)) - case "h": + case "H": + c.focusPane(paneContent) return c, c.tabManager.SetActive(0) case "p", "ctrl+p", "v": output := config.VideoWindow @@ -532,7 +533,7 @@ func (c *Controller) showHelp() tea.Cmd { {Key: "j / k, enter", Desc: "In navigation: select a library, then open it"}, {Key: "g, then A–Z / #", Desc: "Jump to a letter (or click the right-hand alphabet)"}, {Key: "Home / End / PgUp / PgDn", Desc: "First / last item / previous / next page"}, - {Key: "h", Desc: "Home"}, + {Key: "H", Desc: "Home"}, {Key: "ctrl+s", Desc: "Global Settings"}, {Key: "L", Desc: "Libraries"}, {Key: "u", Desc: "Switch User"}, diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7c00395..35dabd5 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -4,14 +4,75 @@ import ( "testing" "time" + "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" "github.com/ygelfand/plexctl/internal/tui/player" "github.com/ygelfand/plexctl/internal/tui/view" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" "github.com/ygelfand/plexctl/internal/ui" "go.dalton.dog/bubbleup" ) +func TestPosterNavigationKeysReachContent(t *testing.T) { + for _, tt := range []struct { + name string + tab int + }{ + {name: "home", tab: 0}, + {name: "movies", tab: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + c := focusController() + c.alert = *bubbleup.NewAlertModel(40, true, time.Second) + c.tabManager.SetActive(tt.tab) + grid := poster.NewPosterGrid(make([]components.Metadata, 12), ui.PlexctlTheme) + grid.SetSize(3*ui.PosterStepWidth, 2*ui.PosterTotalHeight) + grid.SetCursor(1) + c.tabManager.tabModels[tt.tab] = grid + for _, step := range []struct { + key string + want int + }{ + {key: "l", want: 2}, {key: "h", want: 1}, + {key: "j", want: 4}, {key: "down", want: 7}, + {key: "k", want: 4}, {key: "up", want: 1}, + {key: "left", want: 0}, {key: "right", want: 1}, + {key: "h", want: 0}, {key: "h", want: 0}, + } { + c.Update(ui.KeyPress(step.key)) + if grid.Cursor != step.want || c.tabManager.activeTabIdx != tt.tab { + t.Fatalf("%s selected poster %d in tab %d; want poster %d in tab %d", + step.key, grid.Cursor, c.tabManager.activeTabIdx, step.want, tt.tab) + } + } + }) + } +} + +func TestHomeShortcutFocusesHome(t *testing.T) { + for _, tt := range []struct { + name string + focus paneFocus + }{ + {name: "content", focus: paneContent}, + {name: "sidebar", focus: paneSidebar}, + {name: "player", focus: panePlayer}, + } { + t.Run(tt.name, func(t *testing.T) { + c := focusController() + c.alert = *bubbleup.NewAlertModel(40, true, time.Second) + c.playerStatus = player.PlayerStatus{State: operations.StatePlaying} + c.tabManager.SetActive(1) + c.focusPane(tt.focus) + c.Update(ui.KeyPress("H")) + if c.tabManager.activeTabIdx != 0 || c.focus != paneContent { + t.Fatal("H did not open Home and focus its content") + } + }) + } +} + func TestRightClickNavigatesBackFromAnotherPane(t *testing.T) { for _, tt := range []struct { name string diff --git a/internal/tui/view/home.go b/internal/tui/view/home.go index c9c3b1e..e91e304 100644 --- a/internal/tui/view/home.go +++ b/internal/tui/view/home.go @@ -196,21 +196,11 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "up", "k": - if v.activeList > 0 { - v.lists[v.activeList].Active = false - v.activeList-- - v.lists[v.activeList].Active = true - v.syncViewport() - shouldRedraw = true - } + cmds = append(cmds, v.moveList(-1)) + shouldRedraw = true case "down", "j": - if v.activeList < len(v.lists)-1 { - v.lists[v.activeList].Active = false - v.activeList++ - v.lists[v.activeList].Active = true - v.syncViewport() - shouldRedraw = true - } + cmds = append(cmds, v.moveList(1)) + shouldRedraw = true case "left", "h", "right", "l": shouldRedraw = true case "r": @@ -232,6 +222,26 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, tea.Batch(cmds...) } +func (v *HomeView) moveList(delta int) tea.Cmd { + next := v.activeList + delta + if next < 0 || next >= len(v.lists) { + return nil + } + + current := v.lists[v.activeList] + column := current.Cursor - current.Offset + current.Active = false + v.activeList = next + target := v.lists[next] + target.Active = true + // Each row scrolls independently. Follow the visible column, clamping to + // the nearest poster when the destination row has fewer items. + target.Cursor = min(target.Offset+column, len(target.Items)-1) + cmd := target.Sync() + v.syncViewport() + return cmd +} + func (v *HomeView) syncViewport() { targetLine := 0 for i := 0; i < v.activeList; i++ { @@ -299,8 +309,8 @@ func (v *HomeView) HelpKeys() []ui.HelpKey { } } return []ui.HelpKey{ - {Key: "↑/↓", Desc: "Switch Section"}, - {Key: "←/→", Desc: "Browse Items"}, + {Key: "j/k, ↓/↑", Desc: "Switch rows in the same column"}, + {Key: "h/l, ←/→", Desc: "Browse Items"}, {Key: "enter", Desc: "View Details"}, } } diff --git a/internal/tui/view/home_test.go b/internal/tui/view/home_test.go new file mode 100644 index 0000000..bf2c34d --- /dev/null +++ b/internal/tui/view/home_test.go @@ -0,0 +1,92 @@ +package view + +import ( + "fmt" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestHomeVerticalNavigationFollowsVisibleColumn(t *testing.T) { + for _, tt := range []struct { + name string + key string + from int + sourceCursor int + sourceOffset int + targetCursor int + targetOffset int + targetCount int + want int + }{ + {name: "down ignores previous selection", key: "j", sourceCursor: 2, targetCount: 12, want: 2}, + {name: "up ignores previous selection", key: "k", from: 1, sourceCursor: 1, targetCursor: 3, targetCount: 12, want: 1}, + {name: "down arrow into scrolled row", key: "down", sourceCursor: 7, sourceOffset: 5, targetCursor: 8, targetOffset: 8, targetCount: 12, want: 10}, + {name: "up arrow into scrolled row", key: "up", from: 1, sourceCursor: 6, sourceOffset: 5, targetCursor: 11, targetOffset: 8, targetCount: 12, want: 9}, + {name: "short row selects closest poster", key: "j", sourceCursor: 3, targetCount: 2, want: 1}, + {name: "last partial row selects closest poster", key: "down", sourceCursor: 3, targetCursor: 11, targetOffset: 10, targetCount: 12, want: 11}, + } { + t.Run(tt.name, func(t *testing.T) { + ui.GetLayout().Update(ui.SidebarTotalWidth+ui.MainOverhead+4*ui.PosterStepWidth, 50, false) + v := NewHomeView(ui.PlexctlTheme) + v.loading = false + for row := range 2 { + list := poster.NewPosterList(row, fmt.Sprintf("Row %d", row), nil, ui.PlexctlTheme) + count := 12 + if row != tt.from { + count = tt.targetCount + } + for i := range count { + list.Items = append(list.Items, &poster.PosterItem{Metadata: components.Metadata{ + Title: fmt.Sprintf("Row %d Movie %d", row, i), + }}) + } + v.lists = append(v.lists, list) + } + v.Update(tea.WindowSizeMsg{}) + source, target := v.lists[tt.from], v.lists[1-tt.from] + source.Cursor, source.Offset, source.Active = tt.sourceCursor, tt.sourceOffset, true + target.Cursor, target.Offset = tt.targetCursor, tt.targetOffset + v.activeList = tt.from + v.updateViewportContent() + v.Update(ui.KeyPress(tt.key)) + wantTitle := fmt.Sprintf("Row %d Movie %d", 1-tt.from, tt.want) + got := v.GetSelectedMetadata() + if got == nil { + t.Fatal("vertical movement lost the selection") + } + if got.Title != wantTitle { + t.Fatalf("%s selected %q; want %q", tt.key, got.Title, wantTitle) + } + if target.Offset != tt.targetOffset { + t.Fatal("vertical movement scrolled the destination row away from its visible posters") + } + if source.Active || !target.Active { + t.Fatal("selection highlight did not move to the destination row") + } + }) + } +} + +func TestHomeVerticalNavigationStopsAtEdges(t *testing.T) { + v := NewHomeView(ui.PlexctlTheme) + for _, key := range []string{"j", "k", "down", "up"} { + v.Update(ui.KeyPress(key)) + if v.GetSelectedMetadata() != nil { + t.Fatal("empty Home view gained a selection") + } + } + list := poster.NewPosterList(0, "Movies", nil, ui.PlexctlTheme) + list.Items = []*poster.PosterItem{{Metadata: components.Metadata{Title: "Only movie"}}} + list.Active = true + v.lists = []*poster.PosterList{list} + for _, key := range []string{"j", "k", "down", "up"} { + v.Update(ui.KeyPress(key)) + if v.GetSelectedMetadata().Title != "Only movie" || !list.Active { + t.Fatal("vertical movement changed selection at the edge") + } + } +} diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 430891f..2bb6f6b 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -107,7 +107,7 @@ func (m *PosterList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.WindowSizeMsg: - return m, m.fetchVisible() + return m, m.fixScroll() } if !m.Active { diff --git a/internal/tui/widget/poster/list_test.go b/internal/tui/widget/poster/list_test.go new file mode 100644 index 0000000..61efe3c --- /dev/null +++ b/internal/tui/widget/poster/list_test.go @@ -0,0 +1,21 @@ +package poster + +import ( + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestPosterListResizeKeepsSelectionVisible(t *testing.T) { + ui.GetLayout().Update(ui.SidebarTotalWidth+ui.MainOverhead+6*ui.PosterStepWidth, 50, false) + m := NewPosterList(0, "Movies", make([]components.Metadata, 12), ui.PlexctlTheme) + m.Cursor = 5 + m.Sync() + ui.GetLayout().Update(ui.SidebarTotalWidth+ui.MainOverhead+3*ui.PosterStepWidth, 50, false) + m.Update(tea.WindowSizeMsg{}) + if m.Cursor != 5 || m.Cursor < m.Offset || m.Cursor >= m.Offset+3 { + t.Fatalf("resize left selection %d outside the row beginning at %d", m.Cursor, m.Offset) + } +} From 9739d07c4ad210189e841a4f82a4fc544865bc67 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:17:41 -0400 Subject: [PATCH 13/21] feat: add keyboard focus to the alphabet rail Move right from content with Ctrl+L, select a letter with j/k or arrows, and press Enter to jump and return to content. Ctrl+H or Escape cancels. Preserve the visible letter cursor through resizing and nested music views, and release focus when the active view no longer has a visible rail. Document the controls and cover focus routing, cancellation, folded mouse targets, library resizing, and artist/album/track navigation with tests. Validation: race/shuffled integration suite, both golangci-lint modes, docs build, and 12 keyboard QA scenarios in an isolated tmux pane. --- README.md | 7 +- docs/content/tui.mdx | 19 ++- internal/tui/focus.go | 72 +++++++++- internal/tui/focus_test.go | 132 ++++++++++++++++++ internal/tui/tui.go | 25 ++-- internal/tui/view/detail/music_detail.go | 18 ++- internal/tui/view/detail/music_detail_test.go | 52 +++++++ internal/tui/view/home.go | 7 + internal/tui/view/media.go | 15 +- internal/tui/view/media_test.go | 33 +++++ internal/ui/alphabet.go | 42 +++++- internal/ui/alphabet_test.go | 31 ++++ 12 files changed, 424 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 536f7e8..5306cc5 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ click to pause, scroll to seek, and right click to return. **V** switches librar list/poster views. **Ctrl+H/J/K/L** moves focus left/down/up/right between the sidebar, library, -and active player. The focused pane has a highlighted border. In the sidebar, +alphabet rail, and active player. The focused pane is highlighted. In the sidebar, use **j/k** or the arrows to select a library, then **Enter** to open it. **L** opens library settings; **Ctrl+S** opens global settings. @@ -117,7 +117,10 @@ album starts its first playable track. Click the right-hand **A–Z rail**, or press **g then a letter**, to jump through movies, TV shows, artists, albums, and track titles. Missing library pages load -automatically. **Home / End** reach the first/last item; **Page Up / Page Down** +automatically. From the library, **Ctrl+L** focuses the rail: **j/k** or **Down/Up** +select a letter, and **Enter** jumps to it and returns focus to the content. +**Ctrl+H** or **Esc** returns without jumping. In the content pane, +**Home / End** reach the first/last item; **Page Up / Page Down** move by a page. The library footer shows how many items have loaded. Use **Space** to pause, **[ / ]** to seek, **+ / -** for volume, **m** to mute, diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index e048c05..efaa774 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -44,7 +44,8 @@ mpv's Space, Right/Left, and q bindings, including any custom bindings you set. - **`H`** (uppercase): Jump directly to Home tab. Lowercase **`h`** moves left within the current view. - **`?`**: Show help overlay. -**Ctrl+H** reaches the left navigation; **Ctrl+L** returns to the library. +From the library, **Ctrl+H** reaches the left navigation; **Ctrl+L** returns +from the sidebar to the library. Press **Ctrl+L** again to focus the alphabet rail. Use **j/k** or **Up/Down** in the sidebar, then **Enter** (or **l/Right**) to open the selected entry. The sidebar scrolls to keep the selection visible. Holding a pane shortcut stops at the edge instead of cycling through panes. @@ -52,8 +53,9 @@ a pane shortcut stops at the edge instead of cycling through panes. While playing, **Ctrl+J** moves from the library to the player below, and **Ctrl+K** moves back up. In the player, **h/l** or **Left/Right** seek, **j/k** or **Down/Up** adjust volume, and **Enter** pauses/resumes. **Esc** -returns focus to the library. A highlighted border and the footer identify the -focused pane; clicking a pane focuses it too. Dialogs retain their own input. +returns focus to the library. The footer and highlighted border or bracketed +letter identify the focused pane. Clicking a pane focuses it too. Dialogs retain +their own input. ## Features @@ -94,9 +96,16 @@ to numbers and other characters. Plex sort titles are respected; accented Latin names share their base letter. Albums are alphabetical; tracks retain album order and jump by song title. The rail folds into columns in shorter panes. +From the content pane, **Ctrl+L** focuses the rail at the current title's letter. +Use **j/k** or **Down/Up** to move the bracketed selection, then **Enter** to jump +and return focus to the content. **Home / End** select **# / Z** while the rail is +focused. **Ctrl+H**, **h/Left**, or **Esc** returns without jumping. Moving the +letter selection leaves the current title unchanged until you press **Enter**. + Library jumps fetch missing pages automatically and show the loaded/total count. -Press **Esc** to cancel a pending jump, **Home** for the first item, **End** for the -last item (loading remaining pages), or **Page Up / Page Down** to move by a page. +In the content pane, press **Esc** to cancel a pending jump, **Home** for the first +item, **End** for the last item (loading remaining pages), or **Page Up / Page Down** +to move by a page. Fetched pages stay available for subsequent jumps while the TUI is open. Music plays in the current TUI by default, without opening an mpv window, even diff --git a/internal/tui/focus.go b/internal/tui/focus.go index faddb73..b226f78 100644 --- a/internal/tui/focus.go +++ b/internal/tui/focus.go @@ -12,16 +12,50 @@ const ( paneContent paneFocus = iota paneSidebar panePlayer + paneAlphabet ) func (c *Controller) focusPane(pane paneFocus) { + if c.focusedAlphabet != nil { + c.focusedAlphabet.Focused = false + c.focusedAlphabet = nil + } if pane == panePlayer && !c.playerStatus.Active() { pane = paneContent } + if pane == paneAlphabet { + if rail := c.alphabetRail(); rail != nil && rail.Width > 0 { + letter := "#" + if provider, ok := c.tabManager.ActiveModel().(ui.PlayableProvider); ok { + if meta := provider.GetSelectedMetadata(); meta != nil { + letter = ui.AlphabetLetter(*meta) + } + } + rail.Focus(letter) + c.focusedAlphabet = rail + } else { + pane = paneContent + } + } c.focus = pane c.tabManager.navbar.SetFocused(pane == paneSidebar) } +func (c *Controller) alphabetRail() *ui.AlphabetRail { + if target, ok := c.tabManager.ActiveModel().(ui.AlphabetNavigator); ok && target.CanJumpLetters() { + return target.AlphabetRail() + } + return nil +} + +func (c *Controller) validateAlphabetFocus() { + if c.focus == paneAlphabet { + if rail := c.alphabetRail(); rail == nil || rail.Width == 0 || rail != c.focusedAlphabet { + c.focusPane(paneContent) + } + } +} + func (c *Controller) paneBorder(pane paneFocus) lipgloss.TerminalColor { if c.focus == pane { return ui.Accent(c.theme) @@ -32,15 +66,23 @@ func (c *Controller) paneBorder(pane paneFocus) lipgloss.TerminalColor { // Pane keys run after modal input handling, so text fields keep their editing // keys. Directional moves stop at the edge, including when a key is held down. func (c *Controller) paneKey(key string) (tea.Cmd, bool) { + c.validateAlphabetFocus() switch key { case "ctrl+h": - c.focusPane(paneSidebar) + if c.focus == paneAlphabet { + c.focusPane(paneContent) + } else { + c.focusPane(paneSidebar) + } case "ctrl+l": - if c.focus == paneSidebar { + switch c.focus { + case paneSidebar: c.focusPane(paneContent) + case paneContent: + c.focusPane(paneAlphabet) } case "ctrl+j": - if c.focus == paneContent && c.playerStatus.Active() { + if (c.focus == paneContent || c.focus == paneAlphabet) && c.playerStatus.Active() { c.focusPane(panePlayer) } case "ctrl+k": @@ -54,6 +96,30 @@ func (c *Controller) paneKey(key string) (tea.Cmd, bool) { } func (c *Controller) focusedKey(key string) (tea.Cmd, bool) { + if c.focus == paneAlphabet { + rail := c.focusedAlphabet + switch key { + case "j", "down": + rail.MoveCursor(1) + case "k", "up": + rail.MoveCursor(-1) + case "home": + rail.MoveCursor(-len(ui.AlphabetLetters)) + case "end": + rail.MoveCursor(len(ui.AlphabetLetters)) + case "enter": + letter := rail.SelectedLetter() + c.focusPane(paneContent) + if target, ok := c.tabManager.ActiveModel().(ui.LetterJumper); ok { + return target.JumpToLetter(letter), true + } + case "esc", "h", "left": + c.focusPane(paneContent) + default: + return nil, false + } + return nil, true + } if c.focus == paneSidebar { nav := c.tabManager.navbar switch key { diff --git a/internal/tui/focus_test.go b/internal/tui/focus_test.go index 57fbb06..c1c1b80 100644 --- a/internal/tui/focus_test.go +++ b/internal/tui/focus_test.go @@ -1,8 +1,10 @@ package tui import ( + "strings" "testing" + "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" "github.com/ygelfand/plexctl/internal/tui/player" @@ -95,3 +97,133 @@ func TestPaneKeysRespectDialogsAndMouseFocus(t *testing.T) { t.Fatal("content click did not restore keyboard focus") } } + +type alphabetTab struct { + letterTab + rail *ui.AlphabetRail + meta components.Metadata +} + +func (m *alphabetTab) AlphabetRail() *ui.AlphabetRail { return m.rail } +func (m *alphabetTab) GetSelectedMetadata() *components.Metadata { return &m.meta } +func (m *alphabetTab) View() string { + if m.rail == nil { + return "" + } + return m.rail.View(ui.AlphabetLetter(m.meta), ui.PlexctlTheme) +} + +func alphabetFocusController() (*Controller, *alphabetTab) { + c := focusController() + rail := ui.NewAlphabetRail(50, 0, 27) + tab := &alphabetTab{ + rail: &rail, + meta: components.Metadata{Title: "The Matrix", TitleSort: ui.Ptr("Matrix")}, + } + c.tabManager.tabModels[0] = tab + return c, tab +} + +func TestAlphabetFocusRequiresEnterToJump(t *testing.T) { + c, tab := alphabetFocusController() + c.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + if c.focus != paneContent { + t.Fatal("Ctrl+L from sidebar skipped content") + } + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + if c.focus != paneAlphabet || !tab.rail.Focused || !strings.Contains(tab.View(), "[M]") { + t.Fatal("Ctrl+L did not focus the rail at the selected title's sort letter") + } + for _, step := range []struct{ key, letter string }{ + {"j", "N"}, {"down", "O"}, {"k", "N"}, {"up", "M"}, + {"home", "#"}, {"up", "#"}, {"end", "Z"}, {"j", "Z"}, {"k", "Y"}, + {"l", "Y"}, {"right", "Y"}, + } { + c.Update(ui.KeyPress(step.key)) + if got := tab.rail.SelectedLetter(); got != step.letter { + t.Fatalf("%s selected %q, want %q", step.key, got, step.letter) + } + if tab.letter != "" || tab.last != nil { + t.Fatalf("%s changed content before Enter", step.key) + } + } + for range 8 { + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + } + if c.focus != paneAlphabet || tab.rail.SelectedLetter() != "Y" { + t.Fatal("holding Ctrl+L reset the letter or moved beyond the rail") + } + c.Update(ui.KeyPress("enter")) + if tab.letter != "Y" || tab.last != nil || c.focus != paneContent || tab.rail.Focused { + t.Fatal("Enter did not jump once and return focus to content") + } +} + +func TestLeavingAlphabetCancelsSelection(t *testing.T) { + for _, key := range []tea.KeyMsg{ + ui.KeyPress("esc"), ui.KeyPress("h"), ui.KeyPress("left"), {Type: tea.KeyCtrlH}, + } { + t.Run(key.String(), func(t *testing.T) { + c, tab := alphabetFocusController() + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + c.Update(ui.KeyPress("j")) + c.Update(key) + if c.focus != paneContent || tab.rail.Focused || tab.letter != "" || tab.last != nil { + t.Fatal("leaving the rail jumped or sent a key to content") + } + c.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + if c.focus != paneSidebar { + t.Fatal("Ctrl+H from content did not continue left to the sidebar") + } + }) + } +} + +func TestAlphabetFocusFollowsVisibleRail(t *testing.T) { + for _, test := range []struct { + name string + hide func(*Controller, *alphabetTab) + }{ + {"resize", func(_ *Controller, tab *alphabetTab) { tab.rail.SetBounds(8, 0, 3) }}, + {"detail without rail", func(_ *Controller, tab *alphabetTab) { tab.rail = nil }}, + {"replacement tab", func(c *Controller, _ *alphabetTab) { c.tabManager.tabModels[0] = &mouseOverlay{} }}, + {"replacement rail", func(_ *Controller, tab *alphabetTab) { + rail := ui.NewAlphabetRail(50, 0, 27) + tab.rail = &rail + }}, + } { + t.Run(test.name, func(t *testing.T) { + c, tab := alphabetFocusController() + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + old := tab.rail + test.hide(c, tab) + ui.GetLayout().Update(100, 40, false) + c.renderBaseView() + if c.focus != paneContent || c.focusedAlphabet != nil || old.Focused { + t.Fatal("focus remained on a hidden or replaced rail") + } + }) + } +} + +func TestAlphabetFocusRespectsDialogsAndPlayer(t *testing.T) { + c, tab := alphabetFocusController() + c.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + overlay := &mouseOverlay{} + c.navigator.Push(overlay) + c.Update(ui.KeyPress("j")) + if tab.rail.SelectedLetter() != "M" || overlay.last.(tea.KeyMsg).String() != "j" { + t.Fatal("dialog input moved the rail behind it") + } + c.navigator.Pop() + c.playerStatus = player.PlayerStatus{State: operations.StatePlaying, AudioOnly: true} + c.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + if c.focus != panePlayer || tab.rail.Focused { + t.Fatal("Ctrl+J did not move from the rail to the active player") + } + c.Update(tea.KeyMsg{Type: tea.KeyCtrlK}) + if c.focus != paneContent { + t.Fatal("Ctrl+K did not return to content") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b4d86fa..4ba49ff 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -41,11 +41,12 @@ type Controller struct { player *player.PlayerManager alert bubbleup.AlertModel - playerStatus player.PlayerStatus - returnTabIdx int - visualizer visualizer.Model - visualTick time.Time - focus paneFocus + playerStatus player.PlayerStatus + returnTabIdx int + visualizer visualizer.Model + visualTick time.Time + focus paneFocus + focusedAlphabet *ui.AlphabetRail } type switchUserSuccessMsg struct{} @@ -531,6 +532,7 @@ func (c *Controller) showHelp() tea.Cmd { {Key: "tab", Desc: "Switch Library"}, {Key: "ctrl+h / j / k / l", Desc: "Focus pane: left / down / up / right"}, {Key: "j / k, enter", Desc: "In navigation: select a library, then open it"}, + {Key: "ctrl+l, j / k, enter", Desc: "From content: focus A–Z, choose a letter, then jump"}, {Key: "g, then A–Z / #", Desc: "Jump to a letter (or click the right-hand alphabet)"}, {Key: "Home / End / PgUp / PgDn", Desc: "First / last item / previous / next page"}, {Key: "H", Desc: "Home"}, @@ -582,6 +584,12 @@ func (c *Controller) renderBaseView() string { // ContentHeight already accounts for player bar if active contentHeight := layout.ContentHeight() + content := "" + if model := c.tabManager.ActiveModel(); model != nil { + content = model.View() + } + c.validateAlphabetFocus() + windowStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true). BorderForeground(c.paneBorder(paneContent)). @@ -589,11 +597,6 @@ func (c *Controller) renderBaseView() string { Width(layout.MainAreaContentWidth() + 2). Height(contentHeight) - content := "" - if model := c.tabManager.ActiveModel(); model != nil { - content = model.View() - } - content = lipgloss.NewStyle().MaxWidth(layout.InnerWidth()).MaxHeight(contentHeight).Render(content) body := windowStyle.Render(content) @@ -624,6 +627,8 @@ func (c *Controller) footerText() string { text = "Navigation | j/k: select | [enter] Open | Ctrl+L: content | [L] Libraries | [?] Help" case panePlayer: text = "Player | h/l: seek | j/k: volume | [space] Pause | Ctrl+K: content | [?] Help" + case paneAlphabet: + text = "Alphabet | j/k or ↑/↓: select | [enter] Jump | Ctrl+H: content | [esc] Cancel | [?] Help" } return ui.Ellipsis(text, max(1, ui.GetLayout().TotalWidth()-2)) } diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go index 37cd3f0..8e3cb26 100644 --- a/internal/tui/view/detail/music_detail.go +++ b/internal/tui/view/detail/music_detail.go @@ -87,6 +87,16 @@ func (v *MusicDetailView) CanJumpLetters() bool { return v.Metadata != nil && (v.Metadata.Type == "artist" || v.Metadata.Type == "album") } +func (v *MusicDetailView) AlphabetRail() *ui.AlphabetRail { + if v.selected != nil { + return v.selected.AlphabetRail() + } + if v.Loading || v.Err != nil || !v.CanJumpLetters() { + return nil + } + return &v.rail +} + func (v *MusicDetailView) JumpToLetter(letter string) tea.Cmd { if v.selected != nil { return v.selected.JumpToLetter(letter) @@ -252,7 +262,7 @@ func (v *MusicDetailView) View() string { lipgloss.NewStyle().Width(infoWidth).Foreground(v.Theme.BrightCyan()).Render(info), ) v.listRect = ui.Rect{} - v.rail = ui.AlphabetRail{} + v.rail.Rect = ui.Rect{} footer := "[p] Play | [v] Player | [esc] Back" switch v.Metadata.Type { case "track": @@ -261,7 +271,7 @@ func (v *MusicDetailView) View() string { case "artist": rect := ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} rect.Height = max(1, ui.GetLayout().ContentHeight()-rect.Y-3) - v.rail = ui.NewAlphabetRail(2+width, rect.Y, rect.Height) + v.rail.SetBounds(2+width, rect.Y, rect.Height) rect.Width -= v.rail.Width if rect != v.albumsRect { v.albumsRect = rect @@ -278,7 +288,7 @@ func (v *MusicDetailView) View() string { default: v.listRect = ui.Rect{X: 2, Y: 2 + lipgloss.Height(header), Width: width} v.listRect.Height = max(4, ui.GetLayout().ContentHeight()-v.listRect.Y-3) - v.rail = ui.NewAlphabetRail(2+width, v.listRect.Y, v.listRect.Height) + v.rail.SetBounds(2+width, v.listRect.Y, v.listRect.Height) v.listRect.Width -= v.rail.Width coverWidth := detailPosterWidth(v.Metadata) cover := v.Poster @@ -298,7 +308,7 @@ func (v *MusicDetailView) View() string { header += "\n\n" + cover v.listRect.Y += lipgloss.Height(cover) + 2 v.listRect.Height -= lipgloss.Height(cover) + 2 - v.rail = ui.NewAlphabetRail(2+width, v.listRect.Y, v.listRect.Height) + v.rail.SetBounds(2+width, v.listRect.Y, v.listRect.Height) v.listRect.Width = width - v.rail.Width } v.items.SetSize(max(1, v.listRect.Width), v.listRect.Height) diff --git a/internal/tui/view/detail/music_detail_test.go b/internal/tui/view/detail/music_detail_test.go index f8992bf..320ae9d 100644 --- a/internal/tui/view/detail/music_detail_test.go +++ b/internal/tui/view/detail/music_detail_test.go @@ -170,3 +170,55 @@ func TestCompactAlbumFitsArtworkTracksAlphabetAndFooter(t *testing.T) { t.Fatal("compact album lost its footer or mouse alphabet") } } + +func TestMusicAlphabetFocusFollowsAlbumAndTrackLists(t *testing.T) { + ui.GetLayout().Update(100, 40, false) + artist := NewMusicDetailView("artist", ui.PlexctlTheme) + if artist.AlphabetRail() != nil { + t.Fatal("loading artist exposed an invisible rail") + } + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "artist", Title: "Artist"}, + children: []components.Metadata{ + {RatingKey: ui.Ptr("a"), Type: "album", Title: "Amber"}, + {RatingKey: ui.Ptr("z"), Type: "album", Title: "Zebra"}, + }}) + artist.View() + albumsRail := artist.AlphabetRail() + albumsRail.Focus("Z") + if !strings.Contains(artist.View(), "[Z]") || artist.GetSelectedMetadata().Title != "Amber" { + t.Fatal("album rail rendering lost focus or changed the selected album") + } + artist.JumpToLetter(albumsRail.SelectedLetter()) + albumsRail.Focused = false + artist.Update(ui.KeyPress("enter")) + if artist.AlphabetRail() != nil { + t.Fatal("loading album exposed the parent artist's rail") + } + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "album", Title: "Zebra"}, + children: []components.Metadata{ + {RatingKey: ui.Ptr("song1"), Type: "track", Title: "Zulu", Index: ui.Ptr(1)}, + {RatingKey: ui.Ptr("song2"), Type: "track", Title: "Amber", Index: ui.Ptr(2)}, + }}) + artist.View() + tracksRail := artist.AlphabetRail() + if tracksRail == nil || tracksRail == albumsRail { + t.Fatal("album did not expose its own track-list rail") + } + tracksRail.Focus("A") + for _, size := range [][2]int{{80, 24}, {100, 40}} { + ui.GetLayout().Update(size[0], size[1], false) + if !strings.Contains(artist.View(), "[A]") || artist.GetSelectedMetadata().Title != "Zulu" { + t.Fatal("track-list resize lost focus or changed the selected track") + } + } + artist.JumpToLetter(tracksRail.SelectedLetter()) + if artist.GetSelectedMetadata().Title != "Amber" { + t.Fatal("rail did not jump by track title") + } + tracksRail.Focused = false + artist.Update(ui.KeyPress("enter")) + artist.Update(detailDataMsg{metadata: &components.Metadata{Type: "track", Title: "Amber"}}) + if artist.AlphabetRail() != nil { + t.Fatal("individual track exposed an invisible rail") + } +} diff --git a/internal/tui/view/home.go b/internal/tui/view/home.go index e91e304..80106b7 100644 --- a/internal/tui/view/home.go +++ b/internal/tui/view/home.go @@ -40,6 +40,13 @@ func (v *HomeView) CanJumpLetters() bool { return ok && target.CanJumpLetters() } +func (v *HomeView) AlphabetRail() *ui.AlphabetRail { + if target, ok := v.details.View.(ui.AlphabetNavigator); ok { + return target.AlphabetRail() + } + return nil +} + func (v *HomeView) JumpToLetter(letter string) tea.Cmd { if target, ok := v.details.View.(ui.LetterJumper); ok { return target.JumpToLetter(letter) diff --git a/internal/tui/view/media.go b/internal/tui/view/media.go index 8d01464..78be12e 100644 --- a/internal/tui/view/media.go +++ b/internal/tui/view/media.go @@ -355,6 +355,19 @@ func (v *MediaView) CanJumpLetters() bool { return true } +func (v *MediaView) AlphabetRail() *ui.AlphabetRail { + if v.details.Active() { + if target, ok := v.details.View.(ui.AlphabetNavigator); ok { + return target.AlphabetRail() + } + return nil + } + if v.err != nil && v.loadedItems == 0 { + return nil + } + return &v.rail +} + func (v *MediaView) JumpToLetter(letter string) tea.Cmd { if v.details.Active() { if target, ok := v.details.View.(ui.LetterJumper); ok { @@ -403,7 +416,7 @@ func (v *MediaView) advanceJump() tea.Cmd { func (v *MediaView) updateBounds() { layout := ui.GetLayout() height := max(1, layout.ContentHeight()-1) - v.rail = ui.NewAlphabetRail(layout.InnerWidth(), 0, height) + v.rail.SetBounds(layout.InnerWidth(), 0, height) rect := ui.Rect{Width: max(1, layout.InnerWidth()-v.rail.Width), Height: height} if rect != v.contentRect { v.contentRect = rect diff --git a/internal/tui/view/media_test.go b/internal/tui/view/media_test.go index 237df04..4d28e9c 100644 --- a/internal/tui/view/media_test.go +++ b/internal/tui/view/media_test.go @@ -152,3 +152,36 @@ func TestUnknownTotalContinuesUntilEmptyPage(t *testing.T) { t.Fatal("empty page did not finish pagination") } } + +func TestLibraryAlphabetFocusSurvivesResize(t *testing.T) { + for _, mediaType := range []string{"movie", "show", "artist"} { + t.Run(mediaType, func(t *testing.T) { + ui.GetLayout().Update(100, 40, false) + v := NewMediaView("library", "Library", ui.PlexctlTheme) + v.Init() + v.Update(ui.MediaPageMsg{SectionID: "library", RequestID: v.requestID, Total: 2, + Metadata: []components.Metadata{ + {RatingKey: ui.Ptr("a"), Type: mediaType, Title: "Amber"}, + {RatingKey: ui.Ptr("z"), Type: mediaType, Title: "Zebra"}, + }}) + v.View() + rail := v.AlphabetRail() + if rail == nil || rail.Width == 0 { + t.Fatal("library did not expose a visible alphabet rail") + } + rail.Focus("Z") + for _, mode := range []ViewMode{ViewModePoster, ViewModeList} { + v.viewMode = mode + ui.GetLayout().Update(80, 24, false) + v.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if !strings.Contains(v.View(), "[Z]") || v.GetSelectedMetadata().Title != "Amber" { + t.Fatal("rendering or resizing lost the rail cursor or changed the title") + } + } + v.JumpToLetter(rail.SelectedLetter()) + if v.GetSelectedMetadata().Title != "Zebra" { + t.Fatal("jump did not select the requested title") + } + }) + } +} diff --git a/internal/ui/alphabet.go b/internal/ui/alphabet.go index 4952633..5ab8b74 100644 --- a/internal/ui/alphabet.go +++ b/internal/ui/alphabet.go @@ -20,9 +20,18 @@ type LetterJumper interface { JumpToLetter(string) tea.Cmd } +type AlphabetNavigator interface { + LetterJumper + AlphabetRail() *AlphabetRail +} + // AlphabetRail folds into additional columns in short panes. The same geometry // drives rendering and hit testing, including after the music player resizes it. -type AlphabetRail struct{ Rect } +type AlphabetRail struct { + Rect + Focused bool + cursor int +} func NewAlphabetRail(right, top, height int) AlphabetRail { if height < 5 || right < 12 { @@ -33,7 +42,25 @@ func NewAlphabetRail(right, top, height int) AlphabetRail { if width+8 > right { return AlphabetRail{} } - return AlphabetRail{Rect{X: right - width, Y: top, Width: width, Height: rows}} + return AlphabetRail{Rect: Rect{X: right - width, Y: top, Width: width, Height: rows}} +} + +// SetBounds retains keyboard selection when the rail folds after a resize. +func (r *AlphabetRail) SetBounds(right, top, height int) { + r.Rect = NewAlphabetRail(right, top, height).Rect +} + +func (r *AlphabetRail) Focus(letter string) { + r.Focused = true + r.cursor = max(0, strings.Index(AlphabetLetters, letter)) +} + +func (r *AlphabetRail) MoveCursor(delta int) { + r.cursor = max(0, min(len(AlphabetLetters)-1, r.cursor+delta)) +} + +func (r AlphabetRail) SelectedLetter() string { + return AlphabetLetters[r.cursor : r.cursor+1] } func (r AlphabetRail) LetterAt(mouse tea.MouseMsg) string { @@ -59,11 +86,18 @@ func (r AlphabetRail) View(active string, theme tint.Tint) string { cell := " " if i < len(AlphabetLetters) { letter := AlphabetLetters[i : i+1] + label := " " + letter + " " style := lipgloss.NewStyle().Foreground(theme.BrightBlack()) - if letter == active { + switch { + case r.Focused && i == r.cursor: + label = "[" + letter + "]" + style = style.Foreground(theme.Black()).Background(Accent(theme)).Bold(true) + case r.Focused && letter == active: + style = style.Foreground(Accent(theme)).Bold(true) + case !r.Focused && letter == active: style = style.Foreground(theme.Black()).Background(Accent(theme)).Bold(true) } - cell = style.Render(" " + letter + " ") + cell = style.Render(label) } rows[y] += cell } diff --git a/internal/ui/alphabet_test.go b/internal/ui/alphabet_test.go index e919da3..0940127 100644 --- a/internal/ui/alphabet_test.go +++ b/internal/ui/alphabet_test.go @@ -51,3 +51,34 @@ func TestAlphabetRespectsSortTitleAccentsAndNumbers(t *testing.T) { } } } + +func TestAlphabetKeyboardSelectionSurvivesFolding(t *testing.T) { + rail := NewAlphabetRail(50, 4, 40) + rail.Focus("L") + rail.MoveCursor(1) + for _, height := range []int{40, 14, 9, 6, 5, 40} { + rail.SetBounds(50, 4, height) + if !rail.Focused || rail.SelectedLetter() != "M" { + t.Fatalf("height %d lost keyboard selection", height) + } + view := ansi.Strip(rail.View("A", PlexctlTheme)) + if strings.Count(view, "[") != 1 || !strings.Contains(view, "[M]") { + t.Fatalf("height %d did not highlight the cursor independently of the active title", height) + } + for y, line := range strings.Split(view, "\n") { + if x := strings.Index(line, "[M]"); x >= 0 { + for offset := range 3 { + mouse := tea.MouseMsg{X: rail.X + x + offset, Y: rail.Y + y, + Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + if got := rail.LetterAt(mouse); got != "M" { + t.Fatalf("height %d: clicking focused letter cell returned %q", height, got) + } + } + } + } + } + rail.Focused = false + if strings.Contains(rail.View("A", PlexctlTheme), "[M]") { + t.Fatal("unfocused rail kept its keyboard cursor") + } +} From d5b0b207f2f7296c9eb7b4b5b6a869678bd3fad2 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:20 -0400 Subject: [PATCH 14/21] fix: support vim navigation across status tabs Allow h/l and arrow keys to pan wide tables without changing the selected row. Size status tables to the content pane so j/k keep selection visible and horizontal scrolling can reveal every column. Preserve mouse targeting and support Shift+wheel and horizontal wheels. Put search Status in a scrollable viewport, retain navigation during indexing, and clamp offsets after resizing. Correct reversed j/k help labels and document the controls across content views. Validation: full race/shuffled integration suite, both golangci-lint modes, docs build, and 11 tmux QA checks with local sessions/history/tasks data. --- README.md | 3 + docs/content/tui.mdx | 7 ++ internal/tui/tui.go | 1 + internal/tui/view/detail/episode_detail.go | 4 +- internal/tui/view/detail/movie_detail.go | 4 +- internal/tui/view/detail/season_detail.go | 5 +- internal/tui/view/detail/show_detail.go | 5 +- internal/tui/view/history.go | 7 +- internal/tui/view/history_test.go | 28 ++++++++ internal/tui/view/libraries.go | 2 +- internal/tui/view/libraries_test.go | 13 ++++ internal/tui/view/search_status.go | 69 ++++++++++++++----- internal/tui/view/search_status_test.go | 78 ++++++++++++++++++++++ internal/tui/view/sessions.go | 7 +- internal/tui/view/sessions_test.go | 60 +++++++++++++++++ internal/tui/view/tasks.go | 7 +- internal/tui/view/tasks_test.go | 13 ++++ internal/ui/table.go | 36 +++++++++- internal/ui/table_test.go | 67 +++++++++++++++++++ 19 files changed, 381 insertions(+), 35 deletions(-) create mode 100644 internal/tui/view/history_test.go create mode 100644 internal/tui/view/libraries_test.go create mode 100644 internal/tui/view/search_status_test.go create mode 100644 internal/tui/view/sessions_test.go create mode 100644 internal/tui/view/tasks_test.go create mode 100644 internal/ui/table_test.go diff --git a/README.md b/README.md index 5306cc5..58bcf93 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,9 @@ list/poster views. alphabet rail, and active player. The focused pane is highlighted. In the sidebar, use **j/k** or the arrows to select a library, then **Enter** to open it. **L** opens library settings; **Ctrl+S** opens global settings. +In Sessions, History, and Tasks, **j/k** select rows and **h/l** scroll wide tables +left/right. The search Status page scrolls with **hjkl** too, including during +indexing. Arrow keys provide the same navigation. Movie and TV cover posters automatically use high-resolution images in Kitty 0.28+ (also through tmux), with text posters as a fallback in other terminals. diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index efaa774..fa35f3e 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -12,6 +12,13 @@ The plexctl Interactive Interface (TUI) provides a rich, full-screen experience - **`Esc`**: Go back or close overlays. - **`q`**: Quit the application. +Sessions, History, and Tasks use **j/k** to select the next/previous row and +**h/l** to scroll columns left/right when the table is wider than the pane. +The search Status page supports **hjkl** scrolling, including while indexing. +Arrow keys work the same way. **Home / End** reach the top/bottom and +**Page Up / Page Down** move by a page. **Shift+wheel** or a horizontal wheel +scrolls tables and Status sideways. + ### Mouse Mouse support is enabled automatically, including inside tmux: diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4ba49ff..6e7d803 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -526,6 +526,7 @@ func (c *Controller) fullReload() tea.Cmd { func (c *Controller) showHelp() tea.Cmd { keys := []ui.HelpKey{ + {Key: "h / j / k / l", Desc: "Navigate content: left / down / up / right (or arrow keys)"}, {Key: "click", Desc: "Open posters, rows, navigation, and actions"}, {Key: "wheel", Desc: "Scroll under pointer; Shift+wheel browses a poster row"}, {Key: "right click", Desc: "Back / close dialog (Shift bypasses mouse capture)"}, diff --git a/internal/tui/view/detail/episode_detail.go b/internal/tui/view/detail/episode_detail.go index 5dd3a60..4c6d0f6 100644 --- a/internal/tui/view/detail/episode_detail.go +++ b/internal/tui/view/detail/episode_detail.go @@ -92,8 +92,8 @@ func (v *EpisodeDetailView) HelpKeys() []ui.HelpKey { {Key: "esc", Desc: "Back"}, {Key: "s", Desc: "Go to Season"}, {Key: "S", Desc: "Go to Show"}, - {Key: "j/up", Desc: "Scroll Synopsis Up"}, - {Key: "k/down", Desc: "Scroll Synopsis Down"}, + {Key: "k/up", Desc: "Scroll Synopsis Up"}, + {Key: "j/down", Desc: "Scroll Synopsis Down"}, } } diff --git a/internal/tui/view/detail/movie_detail.go b/internal/tui/view/detail/movie_detail.go index 16633a7..84d90a0 100644 --- a/internal/tui/view/detail/movie_detail.go +++ b/internal/tui/view/detail/movie_detail.go @@ -99,8 +99,8 @@ func (v *MovieDetailView) hasTrailer() bool { func (v *MovieDetailView) HelpKeys() []ui.HelpKey { keys := []ui.HelpKey{ {Key: "esc", Desc: "Back"}, - {Key: "j/up", Desc: "Scroll Synopsis Up"}, - {Key: "k/down", Desc: "Scroll Synopsis Down"}, + {Key: "k/up", Desc: "Scroll Synopsis Up"}, + {Key: "j/down", Desc: "Scroll Synopsis Down"}, } if v.hasTrailer() { keys = append(keys, ui.HelpKey{Key: "t", Desc: "Watch Trailer"}) diff --git a/internal/tui/view/detail/season_detail.go b/internal/tui/view/detail/season_detail.go index aeeea0f..33733a7 100644 --- a/internal/tui/view/detail/season_detail.go +++ b/internal/tui/view/detail/season_detail.go @@ -142,8 +142,9 @@ func (v *SeasonDetailView) HelpKeys() []ui.HelpKey { {Key: "enter", Desc: "View Episode Details"}, {Key: "S", Desc: "Go to Show"}, {Key: "esc", Desc: "Back"}, - {Key: "j/up", Desc: "Move Up / Scroll"}, - {Key: "k/down", Desc: "Move Down / Scroll"}, + {Key: "k/up", Desc: "Move Up / Scroll"}, + {Key: "j/down", Desc: "Move Down / Scroll"}, + {Key: "h/left, l/right", Desc: "Previous / Next Page of Episodes"}, } } diff --git a/internal/tui/view/detail/show_detail.go b/internal/tui/view/detail/show_detail.go index d105c89..83e4f7d 100644 --- a/internal/tui/view/detail/show_detail.go +++ b/internal/tui/view/detail/show_detail.go @@ -134,8 +134,9 @@ func (v *ShowDetailView) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "enter", Desc: "Select Season"}, {Key: "esc", Desc: "Back"}, - {Key: "j/up", Desc: "Move Up / Scroll"}, - {Key: "k/down", Desc: "Move Down / Scroll"}, + {Key: "k/up", Desc: "Move Up / Scroll"}, + {Key: "j/down", Desc: "Move Down / Scroll"}, + {Key: "h/left, l/right", Desc: "Previous / Next Page of Seasons"}, } } diff --git a/internal/tui/view/history.go b/internal/tui/view/history.go index 9720ae0..2de49eb 100644 --- a/internal/tui/view/history.go +++ b/internal/tui/view/history.go @@ -61,7 +61,7 @@ func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { case tea.WindowSizeMsg: - t.width = msg.Width + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) t.table.SetWidth(t.width) @@ -193,7 +193,8 @@ func (t *HistoryTab) View() string { func (t *HistoryTab) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ - {Key: "j/up", Desc: "Move Up"}, - {Key: "k/down", Desc: "Move Down"}, + {Key: "j/down", Desc: "Next Row"}, + {Key: "k/up", Desc: "Previous Row"}, + {Key: "h/left, l/right", Desc: "Scroll Columns Left / Right"}, } } diff --git a/internal/tui/view/history_test.go b/internal/tui/view/history_test.go new file mode 100644 index 0000000..6f420cd --- /dev/null +++ b/internal/tui/view/history_test.go @@ -0,0 +1,28 @@ +package view + +import ( + "fmt" + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestHistoryTableNavigationPreservesActivation(t *testing.T) { + tab := NewHistoryTab(ui.PlexctlTheme) + rows := navigationRows(len(tab.table.Columns())) + metadata := make([]operations.ListPlaybackHistoryMetadata, len(rows)) + for i := range metadata { + metadata[i] = operations.ListPlaybackHistoryMetadata{RatingKey: ui.Ptr(fmt.Sprint(i)), Type: ui.Ptr("movie")} + } + tab.Update(historyResult{rows: rows, metadata: metadata}) + verifyTableNavigation(t, tab, &tab.table) + _, cmd := tab.Update(ui.KeyPress("enter")) + if cmd == nil { + t.Fatal("Enter did not activate the selected history row") + } + msg, ok := cmd().(ui.SelectMediaMsg) + if !ok || msg.RatingKey != "19" { + t.Fatalf("Enter did not open the navigated history item: %+v", msg) + } +} diff --git a/internal/tui/view/libraries.go b/internal/tui/view/libraries.go index ed7d42b..e6b76e9 100644 --- a/internal/tui/view/libraries.go +++ b/internal/tui/view/libraries.go @@ -91,7 +91,7 @@ func (t *LibrariesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { case tea.WindowSizeMsg: - t.width = msg.Width + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) t.table.SetWidth(t.width) diff --git a/internal/tui/view/libraries_test.go b/internal/tui/view/libraries_test.go new file mode 100644 index 0000000..f3381d3 --- /dev/null +++ b/internal/tui/view/libraries_test.go @@ -0,0 +1,13 @@ +package view + +import ( + "testing" + + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestLibrariesTableNavigation(t *testing.T) { + tab := NewLibrariesTab(ui.PlexctlTheme) + tab.Update(navigationRows(len(tab.table.Columns()))) + verifyTableNavigation(t, tab, &tab.table) +} diff --git a/internal/tui/view/search_status.go b/internal/tui/view/search_status.go index b8c42fb..15679c4 100644 --- a/internal/tui/view/search_status.go +++ b/internal/tui/view/search_status.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" @@ -14,22 +15,25 @@ import ( ) type SearchStatusView struct { - width, height int - theme tint.Tint - isIndexing bool - progress search.IndexProgress - err error - spinner spinner.Model - libraries []plex.LibraryInfo - progressChan chan search.IndexProgress + viewport viewport.Model + theme tint.Tint + isIndexing bool + progress search.IndexProgress + err error + spinner spinner.Model + libraries []plex.LibraryInfo + progressChan chan search.IndexProgress } func NewSearchStatusView(theme tint.Tint, libraries []plex.LibraryInfo) *SearchStatusView { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(ui.Accent(theme)) + vp := viewport.New(0, 0) + vp.SetHorizontalStep(3) return &SearchStatusView{ + viewport: vp, theme: theme, spinner: s, libraries: libraries, @@ -66,10 +70,17 @@ type SearchIndexFinishedMsg struct { } func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if mouse, ok := msg.(tea.MouseMsg); ok { + v.updateViewport() + local, inside := (ui.Rect{X: 2, Y: 2, Width: v.viewport.Width, Height: v.viewport.Height}).Local(mouse) + if !inside { + return v, nil + } + msg = local + } switch msg := msg.(type) { case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height + v.updateViewport() case ui.ThemeChangedMsg: v.theme = msg.Theme v.spinner.Style = lipgloss.NewStyle().Foreground(ui.Accent(v.theme)) @@ -88,12 +99,16 @@ func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.progressChan = nil return v, nil case tea.KeyMsg: - if v.isIndexing { - return v, nil - } + v.updateViewport() switch msg.String() { case "r": return v, v.Refresh() + case "home": + v.viewport.GotoTop() + return v, nil + case "end": + v.viewport.GotoBottom() + return v, nil } case spinner.TickMsg: if v.isIndexing { @@ -103,7 +118,9 @@ func (v *SearchStatusView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return v, nil + var cmd tea.Cmd + v.viewport, cmd = v.viewport.Update(msg) + return v, cmd } func (v *SearchStatusView) waitForProgress() tea.Cmd { @@ -121,6 +138,23 @@ func (v *SearchStatusView) waitForProgress() tea.Cmd { } func (v *SearchStatusView) View() string { + v.updateViewport() + return lipgloss.NewStyle().Padding(2).Render(v.viewport.View()) +} + +func (v *SearchStatusView) updateViewport() { + v.viewport.Width = max(1, ui.GetLayout().InnerWidth()-4) + v.viewport.Height = max(1, ui.GetLayout().ContentHeight()-4) + content := v.content() + // Keep the content at least as wide as the viewport so Bubbles' horizontal + // offset stays nonnegative when the pane is wider than the status text. + content = lipgloss.NewStyle().Width(max(v.viewport.Width, lipgloss.Width(content))).Render(content) + v.viewport.SetContent(content) + v.viewport.SetYOffset(v.viewport.YOffset) + v.viewport.ScrollRight(0) +} + +func (v *SearchStatusView) content() string { idx := search.GetIndex() entries, indexedAt := idx.Snapshot() @@ -143,7 +177,8 @@ func (v *SearchStatusView) View() string { lines = append(lines, titleStyle.Render("LIBRARY STATISTICS")) for _, lib := range v.libraries { - lines = append(lines, labelStyle.Render(lib.Title+":")+valueStyle.Render(fmt.Sprintf("%d items", lib.Count))) + label := labelStyle.Width(max(20, lipgloss.Width(lib.Title)+2)).Render(lib.Title + ":") + lines = append(lines, label+valueStyle.Render(fmt.Sprintf("%d items", lib.Count))) } lines = append(lines, "") @@ -164,11 +199,13 @@ func (v *SearchStatusView) View() string { lines = append(lines, ui.ErrorStyle(v.theme).Render(fmt.Sprintf("Error: %v", v.err))) } - return lipgloss.NewStyle().Padding(2).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.JoinVertical(lipgloss.Left, lines...) } func (v *SearchStatusView) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ + {Key: "j/down, k/up", Desc: "Scroll Down / Up"}, + {Key: "h/left, l/right", Desc: "Scroll Left / Right"}, {Key: "r", Desc: "Reindex Library"}, } } diff --git a/internal/tui/view/search_status_test.go b/internal/tui/view/search_status_test.go new file mode 100644 index 0000000..4cc4cc7 --- /dev/null +++ b/internal/tui/view/search_status_test.go @@ -0,0 +1,78 @@ +package view + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestSearchStatusNavigationWhileIndexing(t *testing.T) { + for _, test := range []struct { + name string + indexing bool + }{ + {name: "idle"}, {name: "indexing", indexing: true}, + } { + t.Run(test.name, func(t *testing.T) { + ui.GetLayout().Update(80, 24, false) + libraries := make([]plex.LibraryInfo, 40) + for i := range libraries { + libraries[i] = plex.LibraryInfo{Title: fmt.Sprintf("Library %02d %s END", i, strings.Repeat("wide ", 12)), Count: i} + } + v := NewSearchStatusView(ui.PlexctlTheme, libraries) + v.isIndexing = test.indexing + v.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + initial := ansi.Strip(v.View()) + v.Update(ui.KeyPress("j")) + if ansi.Strip(v.View()) == initial { + t.Fatal("j did not scroll the status page") + } + v.Update(ui.KeyPress("k")) + if ansi.Strip(v.View()) != initial { + t.Fatal("k did not restore the previous status position") + } + v.Update(ui.KeyPress("down")) + v.Update(ui.KeyPress("up")) + if ansi.Strip(v.View()) != initial { + t.Fatal("up/down differed from j/k") + } + for range 30 { + v.Update(ui.KeyPress("l")) + } + if !strings.Contains(v.View(), "END") { + t.Fatal("l did not reveal the end of a wide library name") + } + for range 30 { + v.Update(ui.KeyPress("h")) + } + if ansi.Strip(v.View()) != initial { + t.Fatal("h did not return to the first column") + } + v.Update(ui.KeyPress("end")) + if !strings.Contains(v.View(), "Library 39") { + t.Fatal("End did not reach the final library") + } + for range 30 { + v.Update(ui.KeyPress("right")) + } + if !strings.Contains(v.View(), "39 items") { + t.Fatal("Right did not reveal the final library count") + } + ui.GetLayout().Update(150, 70, false) + v.Update(tea.WindowSizeMsg{Width: 150, Height: 70}) + view := v.View() + if !strings.Contains(view, "SEARCH INDEX STATUS") || !strings.Contains(view, "Library 39") { + t.Fatalf("resizing did not clamp scroll offsets to the visible content:\n%s", ansi.Strip(view)) + } + if lipgloss.Width(view) > ui.GetLayout().InnerWidth() || lipgloss.Height(view) > ui.GetLayout().ContentHeight() { + t.Fatal("status page overflowed the content pane") + } + }) + } +} diff --git a/internal/tui/view/sessions.go b/internal/tui/view/sessions.go index a789a61..f8d03c0 100644 --- a/internal/tui/view/sessions.go +++ b/internal/tui/view/sessions.go @@ -111,7 +111,7 @@ func (t *SessionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - t.width = msg.Width + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) t.table.SetWidth(t.width) @@ -169,7 +169,8 @@ func (t *SessionsTab) View() string { func (t *SessionsTab) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "s", Desc: "Stop Session"}, - {Key: "j/up", Desc: "Move Up"}, - {Key: "k/down", Desc: "Move Down"}, + {Key: "j/down", Desc: "Next Row"}, + {Key: "k/up", Desc: "Previous Row"}, + {Key: "h/left, l/right", Desc: "Scroll Columns Left / Right"}, } } diff --git a/internal/tui/view/sessions_test.go b/internal/tui/view/sessions_test.go new file mode 100644 index 0000000..2367c06 --- /dev/null +++ b/internal/tui/view/sessions_test.go @@ -0,0 +1,60 @@ +package view + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/ygelfand/plexctl/internal/ui" +) + +func navigationRows(columns int) []table.Row { + rows := make([]table.Row, 40) + for i := range rows { + for column := range columns { + rows[i] = append(rows[i], fmt.Sprintf("R%02d C%d", i, column)) + } + } + return rows +} + +func verifyTableNavigation(t *testing.T, model tea.Model, table *ui.Table) { + t.Helper() + ui.GetLayout().Update(80, 18, false) + model.Update(tea.WindowSizeMsg{Width: 80, Height: 18}) + for range 20 { + model.Update(ui.KeyPress("j")) + } + model.Update(ui.KeyPress("k")) + if table.Cursor() != 19 { + t.Fatal("j/k did not select the expected row") + } + left := ansi.Strip(model.View()) + for range 50 { + model.Update(ui.KeyPress("l")) + } + view := ansi.Strip(model.View()) + row := table.SelectedRow() + if !strings.Contains(view, row[len(row)-1]) || table.Cursor() != 19 || view == left { + t.Fatalf("l did not reveal the selected row's final column:\n%s", view) + } + if lipgloss.Width(view) > ui.GetLayout().InnerWidth() || lipgloss.Height(view) > ui.GetLayout().ContentHeight() { + t.Fatal("status table extends outside the content pane") + } + for range 50 { + model.Update(ui.KeyPress("h")) + } + if ansi.Strip(model.View()) != left { + t.Fatal("h did not restore the original columns and selection") + } +} + +func TestSessionsTableNavigation(t *testing.T) { + tab := NewSessionsTab(ui.PlexctlTheme) + tab.Update(navigationRows(len(tab.table.Columns()))) + verifyTableNavigation(t, tab, &tab.table) +} diff --git a/internal/tui/view/tasks.go b/internal/tui/view/tasks.go index 9159be3..368b22b 100644 --- a/internal/tui/view/tasks.go +++ b/internal/tui/view/tasks.go @@ -92,7 +92,7 @@ func (t *TasksTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - t.width = msg.Width + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) t.table.SetWidth(t.width) @@ -167,7 +167,8 @@ func (t *TasksTab) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "s", Desc: "Start Task"}, {Key: "x", Desc: "Stop Task"}, - {Key: "j/up", Desc: "Move Up"}, - {Key: "k/down", Desc: "Move Down"}, + {Key: "j/down", Desc: "Next Row"}, + {Key: "k/up", Desc: "Previous Row"}, + {Key: "h/left, l/right", Desc: "Scroll Columns Left / Right"}, } } diff --git a/internal/tui/view/tasks_test.go b/internal/tui/view/tasks_test.go new file mode 100644 index 0000000..c9a9c35 --- /dev/null +++ b/internal/tui/view/tasks_test.go @@ -0,0 +1,13 @@ +package view + +import ( + "testing" + + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestTasksTableNavigation(t *testing.T) { + tab := NewTasksTab(ui.PlexctlTheme) + tab.Update(navigationRows(len(tab.table.Columns()))) + verifyTableNavigation(t, tab, &tab.table) +} diff --git a/internal/ui/table.go b/internal/ui/table.go index 0345467..949ec79 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,9 +1,12 @@ package ui import ( + "strings" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" tint "github.com/lrstanley/bubbletint" ) @@ -34,6 +37,7 @@ func NewTable(columns []table.Column, theme tint.Tint) Table { type Table struct { table.Model first int + left int } func (t *Table) syncScroll() { @@ -47,6 +51,18 @@ func (t *Table) syncScroll() { } else if t.Cursor() >= t.first+height { t.first = t.Cursor() - height + 1 } + t.left = max(0, min(t.left, t.contentWidth()-t.Width())) +} + +func (t Table) contentWidth() int { + width := 0 + for _, column := range t.Columns() { + if column.Width > 0 { + // NewTable's header and cell styles add one space on each side. + width += column.Width + 2 + } + } + return width } func (t *Table) SetHeight(height int) { t.Model.SetHeight(max(3, height)) } @@ -61,7 +77,12 @@ func (t *Table) View() string { visible.SetCursor(0) visible.GotoTop() visible.SetCursor(t.Cursor() - t.first) - return visible.View() + visible.SetWidth(max(t.Width(), t.contentWidth())) + lines := strings.Split(visible.View(), "\n") + for i, line := range lines { + lines[i] = ansi.Cut(line, t.left, t.left+t.Width()) + } + return strings.Join(lines, "\n") } // Click selects a visible row and reports whether it can be activated. @@ -80,9 +101,19 @@ func (t *Table) Click(mouse tea.MouseMsg) bool { } func (t Table) Update(msg tea.Msg) (Table, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok && t.Focused() { + switch key.String() { + case "h", "left": + t.left -= 3 + case "l", "right": + t.left += 3 + } + } if mouse, ok := msg.(tea.MouseMsg); ok { if _, inside := (Rect{Width: t.Width(), Height: t.Height() + 2}).Local(mouse); inside { switch delta := WheelDelta(mouse); { + case delta != 0 && (mouse.Shift || mouse.Button == tea.MouseButtonWheelLeft || mouse.Button == tea.MouseButtonWheelRight): + t.left += delta * 3 case delta < 0: t.MoveUp(3) case delta > 0: @@ -108,12 +139,15 @@ func UpdateTableTheme(t *Table, width, height int) { rows := t.Rows() cursor := t.Cursor() cols := t.Columns() + first, left := t.first, t.left *t = NewTable(cols, theme) t.SetRows(rows) t.SetCursor(cursor) t.SetWidth(width) t.SetHeight(GetTableHeight(height)) + t.first, t.left = first, left + t.syncScroll() } // GetTableHeight returns the appropriate table height based on available screen space diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go new file mode 100644 index 0000000..fe63867 --- /dev/null +++ b/internal/ui/table_test.go @@ -0,0 +1,67 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func TestTableNavigationReachesClippedRowsAndColumns(t *testing.T) { + for _, keys := range []struct{ name, left, down, up, right string }{ + {"vim", "h", "j", "k", "l"}, + {"arrows", "left", "down", "up", "right"}, + } { + t.Run(keys.name, func(t *testing.T) { + m := NewTable([]table.Column{{Title: "NAME", Width: 12}, {Title: "DETAIL", Width: 30}, {Title: "STATE", Width: 12}}, PlexctlTheme) + rows := make([]table.Row, 30) + for i := range rows { + rows[i] = table.Row{fmt.Sprintf("Item %02d", i), "A long middle column", fmt.Sprintf("State %02d", i)} + } + m.SetRows(rows) + m.SetHeight(6) + m.SetWidth(20) + for range 20 { + m, _ = m.Update(KeyPress(keys.down)) + } + m, _ = m.Update(KeyPress(keys.up)) + if m.Cursor() != 19 || !strings.Contains(m.View(), "Item 19") { + t.Fatal("vertical navigation left the selected row outside the visible table") + } + for range 40 { + m, _ = m.Update(KeyPress(keys.right)) + } + view := ansi.Strip(m.View()) + if !strings.Contains(view, "STATE") || !strings.Contains(view, "State 19") || m.Cursor() != 19 { + t.Fatalf("horizontal navigation did not reveal the last column of the selected row:\n%s", view) + } + if lipgloss.Width(view) > 20 || lipgloss.Height(view) > 6 { + t.Fatal("table overflowed the content pane") + } + lines := strings.Split(view, "\n") + last := strings.TrimSpace(lines[3]) + if !m.Click(click(15, 3)) || m.SelectedRow()[2] != last { + t.Fatal("horizontal scrolling changed which row a click selected") + } + for range 40 { + m, _ = m.Update(KeyPress(keys.left)) + } + if !strings.Contains(m.View(), "NAME") || strings.Contains(m.View(), "STATE") { + t.Fatal("left navigation did not return to the first column") + } + for range 40 { + m, _ = m.Update(KeyPress(keys.right)) + } + m.SetWidth(90) + if view := m.View(); !strings.Contains(view, "NAME") || !strings.Contains(view, "STATE") { + t.Fatal("widening the pane did not clamp horizontal scrolling") + } + m.SetRows(nil) + m, _ = m.Update(KeyPress(keys.down)) + _ = m.View() + }) + } +} From e2d950bff365c7beaafa4dd5258e453e1753b48e Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:04:46 -0400 Subject: [PATCH 15/21] fix: stabilize Kitty playback and offer approved mpv updates --- README.md | 18 +- cmd/mpv.go | 35 ++++ cmd/play.go | 3 + cmd/root.go | 2 +- cmd/tui.go | 9 +- docs/content/cli/plexctl.md | 1 + docs/content/cli/plexctl_mpv.md | 29 +++ docs/content/cli/plexctl_mpv_install.md | 28 +++ docs/content/index.mdx | 8 +- internal/mpvsetup/check.go | 154 +++++++++++++++ internal/mpvsetup/check_test.go | 118 ++++++++++++ internal/mpvsetup/install.go | 182 ++++++++++++++++++ internal/mpvsetup/install_integration_test.go | 112 +++++++++++ internal/mpvsetup/install_test.go | 77 ++++++++ internal/termgraphics/terminal.go | 44 ++++- internal/termgraphics/terminal_test.go | 4 +- internal/termgraphics/writer.go | 71 +++++-- internal/termgraphics/writer_test.go | 89 +++++++++ internal/tui/player/terminal.go | 35 ++-- internal/tui/player/video_output_test.go | 18 ++ 20 files changed, 999 insertions(+), 38 deletions(-) create mode 100644 cmd/mpv.go create mode 100644 docs/content/cli/plexctl_mpv.md create mode 100644 docs/content/cli/plexctl_mpv_install.md create mode 100644 internal/mpvsetup/check.go create mode 100644 internal/mpvsetup/check_test.go create mode 100644 internal/mpvsetup/install.go create mode 100644 internal/mpvsetup/install_integration_test.go create mode 100644 internal/mpvsetup/install_test.go create mode 100644 internal/termgraphics/writer_test.go diff --git a/README.md b/README.md index 58bcf93..435a5fa 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,21 @@ A robust Plex CLI and TUI written in Go. ## Prerequisites -`plexctl` uses **mpv** for media playback. You must have it installed and available in your PATH. +`plexctl` uses **mpv** for media playback. Before opening the TUI or playing media, +it checks the copy on your PATH against the latest stable mpv release. When mpv +is missing or outdated, it offers installation or an update and shows the exact +package-manager commands. Nothing is installed unless you answer **yes** in an +interactive terminal. You can decline and keep browsing or use an existing player. + +Run `plexctl mpv` to check explicitly, or `plexctl mpv install` to approve an +installation/update. These commands do not require a Plex login. Release checks +are cached for a day; offline checks do not block an installed player. Development +builds newer than the stable release are not downgraded. + +Automatic installation supports Homebrew and common Linux package managers. +Distribution repositories can lag upstream; plexctl rechecks the resolved binary +after installation and reports if it is still outdated. Other systems receive +manual installation instructions from [mpv.io](https://mpv.io/installation/). ### Installation Instructions @@ -93,6 +107,8 @@ Graphics playback requires a compatible terminal and an mpv build that lists the chosen renderer in `mpv --vo=help`. plexctl probes the attached terminal, including through tmux, and uses text output when graphics are unavailable. In tmux 3.3+, playback temporarily zooms its pane and restores the layout on exit. +On Linux, a terminal that confirms local shared-memory access uses that faster +transport; SSH and other terminals keep using portable graphics packets. Mouse support is enabled automatically: click posters, navigation, and dialog options; scroll with the wheel; right click to go back. During terminal video, diff --git a/cmd/mpv.go b/cmd/mpv.go new file mode 100644 index 0000000..cc37c24 --- /dev/null +++ b/cmd/mpv.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/mpvsetup" + "github.com/ygelfand/plexctl/internal/ui" +) + +func checkMPV(cmd *cobra.Command, required, refresh, install, quiet bool) error { + return mpvsetup.Ensure(cmd.Context(), mpvsetup.Options{ + Input: cmd.InOrStdin(), Output: cmd.ErrOrStderr(), Error: cmd.ErrOrStderr(), + CacheDir: config.Get().CacheDir, Required: required, Refresh: refresh, Install: install, Quiet: quiet, + }) +} + +func newMPVCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mpv", Short: "Check the installed mpv and latest stable release", Args: cobra.NoArgs, + GroupID: "media", Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { + return checkMPV(cmd, true, true, false, false) + }, + } + cmd.AddCommand(&cobra.Command{ + Use: "install", Short: "Offer to install or update mpv with your approval", Args: cobra.NoArgs, + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { + return checkMPV(cmd, true, true, true, false) + }, + }) + return cmd +} + +func init() { rootCmd.AddCommand(newMPVCommand()) } diff --git a/cmd/play.go b/cmd/play.go index bac3118..180c90c 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -27,6 +27,9 @@ var playCmd = &cobra.Command{ Args: cobra.ExactArgs(1), GroupID: "media", RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + if err := checkMPV(cmd, true, false, true, true); err != nil { + return err + } output := config.VideoWindow if tctMode { output = config.VideoTCT diff --git a/cmd/root.go b/cmd/root.go index bd51bde..fb60052 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,7 +38,7 @@ var rootCmd = &cobra.Command{ }, RunE: func(cmd *cobra.Command, args []string) error { if config.Get().DefaultToTui { - return runTUI() + return runTUI(cmd) } return cmd.Help() }, diff --git a/cmd/tui.go b/cmd/tui.go index 3273483..cdca737 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -25,11 +25,14 @@ var tuiCmd = &cobra.Command{ Short: "Launch the interactive TUI", GroupID: "tui", RunE: func(cmd *cobra.Command, args []string) error { - return runTUI() + return runTUI(cmd) }, } -func runTUI() (runErr error) { +func runTUI(cmd *cobra.Command) (runErr error) { + if err := checkMPV(cmd, false, false, true, true); err != nil { + return err + } config.IsTUI = true cfg := config.Get() // Always log TUI sessions to a file for easier debugging @@ -45,7 +48,7 @@ func runTUI() (runErr error) { resultChan := make(chan plex.LoaderResult, 1) caps := termgraphics.Probe(os.Stdin, os.Stdout) slog.Info("Terminal graphics", "terminal", caps.Name, "kitty", caps.Kitty, "sixel", caps.Sixel, - "native_posters", caps.NativePosters(), "tmux", termgraphics.InTmux()) + "native_posters", caps.NativePosters(), "shared_memory", caps.KittySharedMemory, "tmux", termgraphics.InTmux()) restore, err := termgraphics.EnablePassthrough() if err != nil { slog.Debug("Graphics passthrough unavailable", "error", err) diff --git a/docs/content/cli/plexctl.md b/docs/content/cli/plexctl.md index 677e957..518b547 100644 --- a/docs/content/cli/plexctl.md +++ b/docs/content/cli/plexctl.md @@ -31,6 +31,7 @@ plexctl [flags] * [plexctl hub](plexctl_hub.md) - Manage hubs * [plexctl library](plexctl_library.md) - Manage libraries * [plexctl login](plexctl_login.md) - Login to Plex using a PIN flow +* [plexctl mpv](plexctl_mpv.md) - Check the installed mpv and latest stable release * [plexctl play](plexctl_play.md) - Play a media item * [plexctl playlist](plexctl_playlist.md) - Manage playlists * [plexctl search](plexctl_search.md) - Manage and use the library search index diff --git a/docs/content/cli/plexctl_mpv.md b/docs/content/cli/plexctl_mpv.md new file mode 100644 index 0000000..d2a970f --- /dev/null +++ b/docs/content/cli/plexctl_mpv.md @@ -0,0 +1,29 @@ +## plexctl mpv + +Check the installed mpv and latest stable release + +``` +plexctl mpv [flags] +``` + +### Options + +``` + -h, --help help for mpv +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity +``` + +### SEE ALSO + +* [plexctl](plexctl.md) - A robust CLI for managing your Plex Media Server +* [plexctl mpv install](plexctl_mpv_install.md) - Offer to install or update mpv with your approval diff --git a/docs/content/cli/plexctl_mpv_install.md b/docs/content/cli/plexctl_mpv_install.md new file mode 100644 index 0000000..24568d3 --- /dev/null +++ b/docs/content/cli/plexctl_mpv_install.md @@ -0,0 +1,28 @@ +## plexctl mpv install + +Offer to install or update mpv with your approval + +``` +plexctl mpv install [flags] +``` + +### Options + +``` + -h, --help help for install +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + -v, --verbose count increase verbosity +``` + +### SEE ALSO + +* [plexctl mpv](plexctl_mpv.md) - Check the installed mpv and latest stable release diff --git a/docs/content/index.mdx b/docs/content/index.mdx index 2a1eac5..d1452e5 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -50,7 +50,11 @@ go install github.com/ygelfand/plexctl@latest ### Prerequisites -You must have **mpv** installed for media playback: +**mpv** is required for media playback. Before starting the TUI or a player, +plexctl checks for missing or outdated mpv and offers installation with your +approval. You can also run `plexctl mpv` or `plexctl mpv install` explicitly. +Package repositories may lag upstream; plexctl verifies the version afterward. +For manual installation: - **macOS**: `brew install mpv` - **Linux**: `sudo apt install mpv` or `sudo pacman -S mpv` @@ -79,4 +83,4 @@ plexctl play 12345 --tct --- -Check out the [CLI Reference](/cli/plexctl) for a full list of commands. \ No newline at end of file +Check out the [CLI Reference](/cli/plexctl) for a full list of commands. diff --git a/internal/mpvsetup/check.go b/internal/mpvsetup/check.go new file mode 100644 index 0000000..b05fae9 --- /dev/null +++ b/internal/mpvsetup/check.go @@ -0,0 +1,154 @@ +// Package mpvsetup checks the playback dependency and offers approved updates. +package mpvsetup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const releaseURL = "https://api.github.com/repos/mpv-player/mpv/releases/latest" + +type Status struct { + Path string + Installed string + Latest string + CheckErr error +} + +func (s Status) NeedsInstall() bool { + return s.Path == "" || s.Latest != "" && older(s.Installed, s.Latest) +} + +var versionPattern = regexp.MustCompile(`^v?([0-9]+)\.([0-9]+)\.([0-9]+)([-+].*)?$`) + +func version(value string) ([3]int, bool) { + var parts [3]int + match := versionPattern.FindStringSubmatch(value) + if match == nil { + return parts, false + } + for i := range parts { + n, err := strconv.Atoi(match[i+1]) + if err != nil { + return parts, false + } + parts[i] = n + } + return parts, true +} + +func older(installed, latest string) bool { + a, okA := version(installed) + b, okB := version(latest) + if !okA || !okB { + return false + } + for i := range a { + if a[i] != b[i] { + return a[i] < b[i] + } + } + // git snapshots after a stable release must never be downgraded to it. + return strings.Contains(installed, "-rc") +} + +func installed(ctx context.Context) (Status, error) { + path, err := exec.LookPath("mpv") + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return Status{}, nil + } + return Status{}, fmt.Errorf("find mpv: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--no-config", "--version").Output() + if err != nil { + return Status{}, fmt.Errorf("check mpv at %s: %w", path, err) + } + fields := strings.Fields(string(out)) + if len(fields) < 2 || fields[0] != "mpv" { + return Status{}, fmt.Errorf("unrecognized mpv version at %s", path) + } + if _, ok := version(fields[1]); !ok { + return Status{}, fmt.Errorf("unrecognized mpv version at %s", path) + } + return Status{Path: path, Installed: strings.TrimPrefix(fields[1], "v")}, nil +} + +type releaseCache struct { + Version string `json:"version"` + Checked time.Time `json:"checked"` +} + +// Check uses a daily cache and a short deadline. An unavailable release service +// must not prevent an installed player from working offline. +func Check(ctx context.Context, cacheDir string, refresh bool) (Status, error) { + s, err := installed(ctx) + if err != nil { + return s, err + } + s.Latest, s.CheckErr = latest(ctx, http.DefaultClient, releaseURL, cacheDir, refresh) + return s, nil +} + +func latest(ctx context.Context, client *http.Client, endpoint, cacheDir string, refresh bool) (string, error) { + path := filepath.Join(cacheDir, "mpv-release.json") + var cached releaseCache + if cacheDir != "" { + data, _ := os.ReadFile(path) + _ = json.Unmarshal(data, &cached) + if _, ok := version(cached.Version); !ok { + cached = releaseCache{} + } + } + if age := time.Since(cached.Checked); !refresh && cached.Version != "" && age >= 0 && age < 24*time.Hour { + return cached.Version, nil + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return cached.Version, fmt.Errorf("check latest mpv release: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "plexctl") + res, err := client.Do(req) + if err != nil { + return cached.Version, fmt.Errorf("latest mpv release is unavailable") + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { + return cached.Version, fmt.Errorf("latest mpv release check returned HTTP %d", res.StatusCode) + } + var release struct { + Tag string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&release); err != nil { + return cached.Version, fmt.Errorf("invalid mpv release response") + } + v := strings.TrimPrefix(release.Tag, "v") + if _, ok := version(v); !ok || strings.ContainsAny(v, "-+") || release.Prerelease || release.Draft { + return cached.Version, fmt.Errorf("mpv release response did not identify a stable version") + } + if cacheDir != "" { + data, _ := json.Marshal(releaseCache{Version: v, Checked: time.Now()}) + if os.MkdirAll(cacheDir, 0o755) == nil { + _ = os.WriteFile(path, data, 0o600) + } + } + return v, nil +} diff --git a/internal/mpvsetup/check_test.go b/internal/mpvsetup/check_test.go new file mode 100644 index 0000000..8cf2ff8 --- /dev/null +++ b/internal/mpvsetup/check_test.go @@ -0,0 +1,118 @@ +package mpvsetup + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestNeedsInstall(t *testing.T) { + for _, tt := range []struct { + name, installed, latest string + want bool + }{ + {"missing", "", "0.41.0", true}, + {"missing offline", "", "", true}, + {"old", "0.37.0", "0.41.0", true}, + {"current", "0.41.0", "0.41.0", false}, + {"newer development build", "0.41.0-1150-g88eb9f2e7", "0.41.0", false}, + {"old development build", "0.40.0-100-gabcdef", "0.41.0", true}, + {"release candidate", "0.41.0-rc1", "0.41.0", true}, + {"newer release candidate", "0.42.0-rc1", "0.41.0", false}, + {"numeric ordering", "0.9.0", "0.41.0", true}, + {"future major", "1.0.0", "0.41.0", false}, + {"offline installed", "0.37.0", "", false}, + } { + t.Run(tt.name, func(t *testing.T) { + s := Status{Installed: tt.installed, Latest: tt.latest} + if tt.installed != "" { + s.Path = "/usr/bin/mpv" + } + if got := s.NeedsInstall(); got != tt.want { + t.Fatalf("NeedsInstall = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMissingMPV(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + s, err := installed(t.Context()) + if err != nil || s.Path != "" { + t.Fatalf("missing mpv: %+v, %v", s, err) + } +} + +func TestLatestReleaseCacheAndOffline(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Header.Get("User-Agent") != "plexctl" { + t.Error("missing release client identification") + } + _, _ = w.Write([]byte(`{"tag_name":"v0.42.0","prerelease":false}`)) + })) + t.Cleanup(srv.Close) + dir := t.TempDir() + for range 2 { + v, err := latest(t.Context(), srv.Client(), srv.URL, dir, false) + if err != nil || v != "0.42.0" { + t.Fatalf("latest: %q, %v", v, err) + } + } + if calls.Load() != 1 { + t.Fatalf("daily cache made %d requests", calls.Load()) + } + if _, err := latest(t.Context(), srv.Client(), srv.URL, dir, true); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatal("explicit refresh did not bypass cache") + } + srv.Close() + v, err := latest(t.Context(), srv.Client(), srv.URL, dir, true) + if err == nil || v != "0.42.0" { + t.Fatalf("offline fallback: %q, %v", v, err) + } +} + +func TestLatestRejectsInvalidRelease(t *testing.T) { + for _, body := range []string{ + `{"tag_name":"git-release","prerelease":true}`, + `{"tag_name":"v0.42.0-rc1","prerelease":true}`, + `{"tag_name":"v0.42.0","draft":true}`, + `{"tag_name":"v0.42.0; touch /tmp/unsafe"}`, + `{"tag_name":"v999999999999999999999999.0.0"}`, + `unavailable`, + } { + t.Run(body, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })) + defer srv.Close() + if v, err := latest(t.Context(), srv.Client(), srv.URL, t.TempDir(), true); err == nil || v != "" { + t.Fatalf("accepted invalid release: %q, %v", v, err) + } + }) + } +} + +func TestLatestExpiredCacheAndRateLimit(t *testing.T) { + dir := t.TempDir() + data, err := json.Marshal(releaseCache{Version: "0.41.0", Checked: time.Now().Add(-48 * time.Hour)}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mpv-release.json"), data, 0o600); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) })) + defer srv.Close() + v, err := latest(t.Context(), srv.Client(), srv.URL, dir, false) + if v != "0.41.0" || err == nil { + t.Fatalf("rate-limited stale cache: %q, %v", v, err) + } +} diff --git a/internal/mpvsetup/install.go b/internal/mpvsetup/install.go new file mode 100644 index 0000000..6bfd66e --- /dev/null +++ b/internal/mpvsetup/install.go @@ -0,0 +1,182 @@ +package mpvsetup + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + + "golang.org/x/term" +) + +const installHelp = "https://mpv.io/installation/" + +// Plan contains only fixed package-manager arguments, never release metadata. +type Plan struct { + Commands [][]string +} + +func installPlan(goos string, lookup func(string) (string, error), missing, root bool) (Plan, error) { + has := func(name string) bool { _, err := lookup(name); return err == nil } + var commands [][]string + privileged := goos != "windows" + switch { + case (goos == "darwin" || goos == "linux") && has("brew"): + privileged = false + action := "upgrade" + if missing { + action = "install" + } + commands = [][]string{{"brew", "update"}, {"brew", action, "mpv"}} + case goos == "linux" && has("apt-get"): + commands = [][]string{{"apt-get", "update"}, {"apt-get", "install", "--yes", "mpv"}} + case goos == "linux" && has("dnf"): + commands = [][]string{{"dnf", "install", "--refresh", "--assumeyes", "mpv"}} + case goos == "linux" && has("pacman"): + // Do not refresh Arch's databases then perform an unsupported partial upgrade. + commands = [][]string{{"pacman", "-S", "--needed", "mpv"}} + case goos == "linux" && has("zypper"): + commands = [][]string{{"zypper", "refresh"}, {"zypper", "--non-interactive", "install", "mpv"}} + case goos == "linux" && has("apk"): + commands = [][]string{{"apk", "add", "--upgrade", "--update-cache", "mpv"}} + default: + return Plan{}, fmt.Errorf("no supported mpv package manager found; install mpv from %s", installHelp) + } + if privileged && !root { + if !has("sudo") { + return Plan{}, fmt.Errorf("installing mpv requires administrator access; see %s", installHelp) + } + for i := range commands { + commands[i] = append([]string{"sudo"}, commands[i]...) + } + } + return Plan{Commands: commands}, nil +} + +type Options struct { + Input io.Reader + Output io.Writer + Error io.Writer + CacheDir string + // Required is false for browsing the TUI without a working player. + Required bool + // Refresh bypasses the daily release cache for the explicit mpv command. + Refresh bool + // Install offers installation; otherwise only report the current state. + Install bool + // Quiet suppresses the report when mpv is current. + Quiet bool +} + +func interactive(r io.Reader) bool { + f, ok := r.(interface{ Fd() uintptr }) + return ok && term.IsTerminal(int(f.Fd())) +} + +func confirm(in io.Reader, out io.Writer) (bool, error) { + if _, err := fmt.Fprint(out, "Proceed with these commands? [y/N] "); err != nil { + return false, err + } + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil { + // EOF, including an unterminated 'yes', is never approval. + return false, nil + } + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + +func runPlan(ctx context.Context, p Plan, opts Options) error { + for _, args := range p.Commands { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = opts.Input, opts.Output, opts.Error + if err := cmd.Run(); err != nil { + return fmt.Errorf("mpv installation command failed: %w", err) + } + } + return nil +} + +// Ensure checks before the TUI takes over stdin. No command can run without an +// explicit affirmative answer from an interactive terminal. +func Ensure(ctx context.Context, opts Options) error { + s, err := Check(ctx, opts.CacheDir, opts.Refresh) + if err != nil { + return dependencyError(opts, err) + } + if !opts.Quiet || s.NeedsInstall() || s.CheckErr != nil { + if s.Path == "" { + _, _ = fmt.Fprintln(opts.Output, "mpv is not installed or is not on PATH.") + } else { + _, _ = fmt.Fprintf(opts.Output, "mpv %s (%s)\n", s.Installed, s.Path) + } + if s.Latest != "" { + _, _ = fmt.Fprintf(opts.Output, "Latest stable release: %s\n", s.Latest) + } + if s.CheckErr != nil { + _, _ = fmt.Fprintf(opts.Error, "Could not verify the latest mpv release: %v.\n", s.CheckErr) + } + } + if !s.NeedsInstall() { + return nil + } + missing := s.Path == "" + if !opts.Install || !interactive(opts.Input) { + if missing { + return dependencyError(opts, fmt.Errorf("mpv is required for playback; run 'plexctl mpv install' in a terminal or see %s", installHelp)) + } + _, _ = fmt.Fprintln(opts.Error, "mpv update available; run 'plexctl mpv install' to approve an update.") + return nil + } + plan, err := installPlan(runtime.GOOS, exec.LookPath, missing, os.Geteuid() == 0) + if err != nil { + return dependencyError(opts, err) + } + var description strings.Builder + description.WriteString("Install the newest mpv available from your configured package repositories:\n") + for _, args := range plan.Commands { + fmt.Fprintf(&description, " %s\n", strings.Join(args, " ")) + } + description.WriteString("Repositories may lag the upstream release; plexctl will verify the version afterward.\n") + if _, err := io.WriteString(opts.Output, description.String()); err != nil { + return fmt.Errorf("show mpv installation commands: %w", err) + } + approved, err := confirm(opts.Input, opts.Output) + if err != nil { + return err + } + if !approved { + if missing { + return dependencyError(opts, fmt.Errorf("mpv installation declined; playback requires mpv")) + } + _, _ = fmt.Fprintln(opts.Output, "mpv update declined; using the installed version.") + return nil + } + if err := runPlan(ctx, plan, opts); err != nil { + return dependencyError(opts, err) + } + after, err := installed(ctx) + if err != nil { + return dependencyError(opts, err) + } + if after.Path == "" { + return dependencyError(opts, fmt.Errorf("mpv is still not on PATH after installation; restart your shell or see %s", installHelp)) + } + _, _ = fmt.Fprintf(opts.Output, "Using mpv %s (%s).\n", after.Installed, after.Path) + if s.Latest != "" && older(after.Installed, s.Latest) { + return dependencyError(opts, fmt.Errorf("mpv %s is still older than %s; your repositories may be behind or another mpv may take precedence on PATH; see %s", after.Installed, s.Latest, installHelp)) + } + return nil +} + +func dependencyError(opts Options, err error) error { + if opts.Required { + return err + } + _, _ = fmt.Fprintf(opts.Error, "%v. You can continue browsing.\n", err) + return nil +} diff --git a/internal/mpvsetup/install_integration_test.go b/internal/mpvsetup/install_integration_test.go new file mode 100644 index 0000000..ea7c976 --- /dev/null +++ b/internal/mpvsetup/install_integration_test.go @@ -0,0 +1,112 @@ +//go:build integration && linux + +package mpvsetup + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func approvalTerminal(t *testing.T, answer string) *os.File { + t.Helper() + master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = master.Close() }) + if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil { + t.Fatal(err) + } + n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN) + if err != nil { + t.Fatal(err) + } + path := fmt.Sprintf("/dev/pts/%d", n) + slave, err := os.OpenFile(path, os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = slave.Close() }) + if _, err := master.WriteString(answer); err != nil { + t.Fatal(err) + } + return slave +} + +func TestEnsureApprovalAndVerification(t *testing.T) { + for _, tt := range []struct { + name, initial, answer, result string + piped, required, wantRun, wantErr bool + }{ + {name: "missing declined", answer: "n\n", required: true, wantErr: true}, + {name: "browse without mpv", answer: "\n"}, + {name: "outdated declined", initial: "0.37.0", answer: "n\n", required: true}, + {name: "piped yes rejected", answer: "yes\n", piped: true, required: true, wantErr: true}, + {name: "approved install", answer: "yes\n", result: "0.41.0", required: true, wantRun: true}, + {name: "approved update", initial: "0.37.0", answer: "y\n", result: "0.41.0", required: true, wantRun: true}, + {name: "repository still old", initial: "0.37.0", answer: "yes\n", result: "0.37.0", required: true, wantRun: true, wantErr: true}, + {name: "manager failure", answer: "yes\n", result: "fail", required: true, wantRun: true, wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + t.Setenv("MPV_TEST_DIR", dir) + t.Setenv("MPV_TEST_RESULT", tt.result) + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o700); err != nil { + t.Fatal(err) + } + } + if tt.initial != "" { + write("mpv", "#!/bin/sh\nprintf 'mpv "+tt.initial+"\\n'\n") + } + // Only these inert scripts can be found on PATH. No system package + // manager is invoked, even when exercising affirmative approval. + write("brew", `#!/bin/sh +printf '%s\n' "$*" >> "$MPV_TEST_DIR/commands" +if [ "$MPV_TEST_RESULT" = fail ]; then exit 9; fi +if [ "$1" != update ]; then + printf '#!/bin/sh\nprintf "mpv %s\\n"\n' "$MPV_TEST_RESULT" > "$MPV_TEST_DIR/mpv" + /bin/chmod 700 "$MPV_TEST_DIR/mpv" +fi +`) + cache, err := json.Marshal(releaseCache{Version: "0.41.0", Checked: time.Now()}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mpv-release.json"), cache, 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + opts := Options{CacheDir: dir, Output: &out, Error: &out, Required: tt.required, Install: true} + if tt.piped { + opts.Input = strings.NewReader(tt.answer) + } else { + opts.Input = approvalTerminal(t, tt.answer) + } + err = Ensure(t.Context(), opts) + if (err != nil) != tt.wantErr { + t.Fatalf("Ensure error=%v; output=%s", err, out.String()) + } + commands, _ := os.ReadFile(filepath.Join(dir, "commands")) + if (len(commands) > 0) != tt.wantRun { + t.Fatalf("unexpected package operations: %s", commands) + } + if tt.wantRun && !strings.Contains(out.String(), "Proceed with these commands? [y/N]") { + t.Fatal("installation ran without showing approval prompt") + } + if tt.name == "repository still old" && !strings.Contains(err.Error(), "still older") { + t.Fatalf("outdated installation was not reported: %v", err) + } + }) + } +} diff --git a/internal/mpvsetup/install_test.go b/internal/mpvsetup/install_test.go new file mode 100644 index 0000000..f29aabb --- /dev/null +++ b/internal/mpvsetup/install_test.go @@ -0,0 +1,77 @@ +package mpvsetup + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestInstallRequiresExplicitApproval(t *testing.T) { + for _, tt := range []struct { + input string + want bool + }{ + {"\n", false}, {"n\n", false}, {"no\n", false}, {"", false}, {"yes", false}, + {"maybe\n", false}, {"y\n", true}, {"YES\n", true}, {" yes \r\n", true}, + } { + t.Run(tt.input, func(t *testing.T) { + var out bytes.Buffer + ok, err := confirm(strings.NewReader(tt.input), &out) + if err != nil || ok != tt.want { + t.Fatalf("approval: %v, %v", ok, err) + } + if !strings.Contains(out.String(), "[y/N]") { + t.Fatal("prompt did not default to declining") + } + }) + } + if interactive(strings.NewReader("yes\n")) { + t.Fatal("piped yes must not authorize installation") + } +} + +func TestInstallPlanScope(t *testing.T) { + for _, tt := range []struct { + goos, manager string + root bool + want string + }{ + {"linux", "apt-get", false, "sudo apt-get update\nsudo apt-get install --yes mpv"}, + {"linux", "dnf", true, "dnf install --refresh --assumeyes mpv"}, + {"linux", "pacman", false, "sudo pacman -S --needed mpv"}, + {"darwin", "brew", false, "brew update\nbrew install mpv"}, + } { + t.Run(tt.manager, func(t *testing.T) { + lookup := func(name string) (string, error) { + if name == tt.manager || name == "sudo" { + return "/bin/" + name, nil + } + return "", exec.ErrNotFound + } + plan, err := installPlan(tt.goos, lookup, true, tt.root) + if err != nil { + t.Fatal(err) + } + var commands []string + for _, args := range plan.Commands { + commands = append(commands, strings.Join(args, " ")) + } + if got := strings.Join(commands, "\n"); got != tt.want { + t.Fatalf("plan:\n%s", got) + } + }) + } + missing := func(string) (string, error) { return "", exec.ErrNotFound } + if _, err := installPlan("windows", missing, true, false); err == nil { + t.Fatal("unsupported platform must provide manual instructions") + } + if _, err := installPlan("linux", func(name string) (string, error) { + if name == "apt-get" { + return "/usr/bin/apt-get", nil + } + return missing(name) + }, true, false); err == nil { + t.Fatal("missing sudo must not attempt privileged installation") + } +} diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go index b05f9d7..4899cc6 100644 --- a/internal/termgraphics/terminal.go +++ b/internal/termgraphics/terminal.go @@ -3,12 +3,15 @@ package termgraphics import ( "context" + "encoding/base64" "fmt" "io" "log/slog" "os" "os/exec" + "path/filepath" "regexp" + "runtime" "strconv" "strings" "sync" @@ -22,8 +25,11 @@ import ( type Capabilities struct { Responded bool Kitty bool - Sixel bool - Name string + // KittySharedMemory is confirmed by a query using a local memory object; + // environment variables cannot establish that the terminal is local. + KittySharedMemory bool + Sixel bool + Name string } // NativePosters requires Unicode placeholders, which basic Kitty graphics @@ -170,7 +176,11 @@ var attributes = regexp.MustCompile(`\x1b\[\?([0-9;]+)c`) var terminalName = regexp.MustCompile(`\x1bP>\|([^\x1b]+)\x1b\\`) func parseCapabilities(reply string) Capabilities { - c := Capabilities{Kitty: strings.Contains(reply, "_Gi=18901;OK")} + c := Capabilities{ + Kitty: strings.Contains(reply, "_Gi=18901;OK"), + KittySharedMemory: strings.Contains(reply, "_Gi=18902;OK"), + } + c.Kitty = c.Kitty || c.KittySharedMemory if match := attributes.FindStringSubmatch(reply); match != nil { c.Responded = true for _, feature := range strings.Split(match[1], ";")[1:] { @@ -185,6 +195,30 @@ func parseCapabilities(reply string) Capabilities { return c } +// sharedMemoryQuery verifies that the terminal can actually read a random +// local memory object. SSH sessions fall back to escape-code transport. +// Linux exposes POSIX shared memory through /dev/shm; other platforms use the +// portable transport until they have a corresponding native probe. +func sharedMemoryQuery() (string, func()) { + noop := func() {} + if runtime.GOOS != "linux" { + return "", noop + } + f, err := os.CreateTemp("/dev/shm", "plexctl-video-probe-") + if err != nil { + return "", noop + } + cleanup := func() { _ = os.Remove(f.Name()) } + _, writeErr := f.Write([]byte{0, 0, 0}) + closeErr := f.Close() + if writeErr != nil || closeErr != nil { + cleanup() + return "", noop + } + name := base64.StdEncoding.EncodeToString([]byte("/" + filepath.Base(f.Name()))) + return "\x1b_Ga=q,t=s,i=18902,s=1,v=1,f=24;" + name + "\x1b\\", cleanup +} + // Probe must run while the caller owns terminal input (before Bubble Tea starts // or while it is suspended). A missing response is unknown, not rejection. func Probe(input *os.File, output io.Writer) Capabilities { @@ -222,7 +256,9 @@ func Probe(input *os.File, output io.Writer) Capabilities { } } }() - _, _ = io.WriteString(output, Wrap("\x1b_Ga=q,U=1,i=18901,s=1,v=1,f=24;AAAA\x1b\\\x1b[c\x1b[>q")) + shm, cleanup := sharedMemoryQuery() + defer cleanup() + _, _ = io.WriteString(output, Wrap("\x1b_Ga=q,U=1,i=18901,s=1,v=1,f=24;AAAA\x1b\\"+shm+"\x1b[c\x1b[>q")) timer := time.NewTimer(350 * time.Millisecond) select { case <-done: diff --git a/internal/termgraphics/terminal_test.go b/internal/termgraphics/terminal_test.go index 7c5dd1a..9dc703d 100644 --- a/internal/termgraphics/terminal_test.go +++ b/internal/termgraphics/terminal_test.go @@ -48,6 +48,8 @@ func TestCapabilities(t *testing.T) { {"unknown", "", Capabilities{}}, {"vte", "\x1b[?61;1;21;22c\x1bP>|VTE(7600)\x1b\\", Capabilities{Responded: true, Name: "VTE(7600)"}}, {"kitty", "\x1b_Gi=18901;OK\x1b\\\x1b[?62;4;22c", Capabilities{Responded: true, Kitty: true, Sixel: true}}, + {"local kitty", "\x1b_Gi=18901;OK\x1b\\\x1b_Gi=18902;OK\x1b\\", Capabilities{Responded: true, Kitty: true, KittySharedMemory: true}}, + {"remote kitty", "\x1b_Gi=18901;OK\x1b\\\x1b_Gi=18902;EBADF:Failed to read image\x1b\\", Capabilities{Responded: true, Kitty: true}}, {"sixel", "\x1b[?64;1;4;22c", Capabilities{Responded: true, Sixel: true}}, {"not four", "\x1b[?64;14;24c", Capabilities{Responded: true}}, } { @@ -85,7 +87,7 @@ func TestKittyPlaybackImageOwnership(t *testing.T) { if _, err := w.Write([]byte("\x1b_Ga=d;\x1b\\\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\")); err != nil { t.Fatal(err) } - want := ansi.TmuxPassthrough(DeleteImage(1073741824)) + ansi.TmuxPassthrough("\x1b_Ga=T,f=24,s=1,v=1,i=1073741824;AAAA\x1b\\") + want := ansi.TmuxPassthrough(DeleteImage(1073741824)+DeleteImage(1073741825)) + ansi.TmuxPassthrough("\x1b_Ga=T,f=24,s=1,v=1,i=1073741824;AAAA\x1b\\") if output.String() != want { t.Fatal("video could overwrite or delete other applications' images") } diff --git a/internal/termgraphics/writer.go b/internal/termgraphics/writer.go index fa830a7..64a40c0 100644 --- a/internal/termgraphics/writer.go +++ b/internal/termgraphics/writer.go @@ -1,6 +1,7 @@ package termgraphics import ( + "bufio" "bytes" "fmt" "io" @@ -18,8 +19,12 @@ type Writer struct { Output io.Writer // Direct observes graphics without wrapping them for tmux. Direct bool - // ImageID assigns frames to this playback session for targeted cleanup. + // ImageID reserves this ID and the next for alternating video frames. + // Replacing an image in place deletes it before the upload finishes. ImageID int + displayedImage int + uploadingImage int + buffered *bufio.Writer pending []byte cursorX, cursorY int mu sync.RWMutex @@ -39,7 +44,18 @@ func (w *Writer) Frame() Frame { return w.frame } -func (w *Writer) Write(p []byte) (int, error) { +func (w *Writer) Write(p []byte) (n int, err error) { + if w.buffered == nil { + w.buffered = bufio.NewWriterSize(w.Output, 32<<10) + } + // A video frame contains hundreds of 4 KiB packets. Batch their writes, + // particularly through tmux, but flush every input chunk so paused frames + // and the final cleanup never wait for more output from mpv. + defer func() { + if flushErr := w.buffered.Flush(); err == nil && flushErr != nil { + n, err = 0, flushErr + } + }() w.pending = append(w.pending, p...) for len(w.pending) > 0 { start := bytes.IndexByte(w.pending, '\x1b') @@ -47,7 +63,7 @@ func (w *Writer) Write(p []byte) (int, error) { start = len(w.pending) } if start > 0 { - if _, err := w.Output.Write(w.pending[:start]); err != nil { + if _, err := w.buffered.Write(w.pending[:start]); err != nil { return 0, err } w.pending = w.pending[start:] @@ -73,14 +89,14 @@ func (w *Writer) Write(p []byte) (int, error) { x, _ := strconv.Atoi(col) w.cursorX, w.cursorY = max(0, x-1), max(0, y-1) } - if _, err := w.Output.Write(w.pending[:end+1]); err != nil { + if _, err := w.buffered.Write(w.pending[:end+1]); err != nil { return 0, err } w.pending = w.pending[end+1:] continue } if w.pending[1] != '_' && w.pending[1] != 'P' { - if _, err := w.Output.Write(w.pending[:2]); err != nil { + if _, err := w.buffered.Write(w.pending[:2]); err != nil { return 0, err } w.pending = w.pending[2:] @@ -112,20 +128,13 @@ func (w *Writer) Write(p []byte) (int, error) { w.mu.Unlock() } if w.ImageID != 0 && strings.HasPrefix(sequence, "\x1b_G") { - if head, payload, ok := strings.Cut(sequence, ";"); ok { - switch { - case strings.Contains(head, "a=T"): - sequence = fmt.Sprintf("%s,i=%d;%s", head, w.ImageID, payload) - case strings.Contains(head, "a=d"): - sequence = DeleteImage(w.ImageID) - } - } + sequence = w.kittyFrame(sequence) } packet := sequence if !w.Direct { packet = ansi.TmuxPassthrough(sequence) } - if _, err := io.WriteString(w.Output, packet); err != nil { + if _, err := w.buffered.WriteString(packet); err != nil { return 0, err } w.pending = w.pending[end:] @@ -133,6 +142,40 @@ func (w *Writer) Write(p []byte) (int, error) { return len(p), nil } +func (w *Writer) kittyFrame(sequence string) string { + head, payload, ok := strings.Cut(sequence, ";") + if !ok { + return sequence + } + fields := "," + strings.TrimPrefix(head, "\x1b_G") + "," + switch { + case strings.Contains(head, "a=T"): + w.uploadingImage = w.ImageID + if w.displayedImage == w.ImageID { + w.uploadingImage++ + } + sequence = fmt.Sprintf("%s,i=%d;%s", head, w.uploadingImage, payload) + case strings.Contains(head, "a=d"): + w.displayedImage, w.uploadingImage = 0, 0 + return w.DeleteImages() + } + // Keep the previous frame visible until the last chunk of its replacement + // is delivered. Reusing a single ID makes moving video flicker or go black, + // even when mpv reports no dropped frames. Two IDs also bound image memory. + if w.uploadingImage != 0 && (!strings.Contains(fields, ",m=1,") || strings.Contains(fields, ",t=s,")) { + if w.displayedImage != 0 { + sequence += DeleteImage(w.displayedImage) + } + w.displayedImage, w.uploadingImage = w.uploadingImage, 0 + } + return sequence +} + +// DeleteImages removes only the two images owned by this playback session. +func (w *Writer) DeleteImages() string { + return DeleteImage(w.ImageID) + DeleteImage(w.ImageID+1) +} + func DeleteImage(id int) string { return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id) } diff --git a/internal/termgraphics/writer_test.go b/internal/termgraphics/writer_test.go new file mode 100644 index 0000000..0055dd8 --- /dev/null +++ b/internal/termgraphics/writer_test.go @@ -0,0 +1,89 @@ +package termgraphics + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestVideoRetainsPreviousFrameDuringUpload(t *testing.T) { + const first = "\x1b_Ga=T,f=24,s=2,v=1,m=1;AAAA\x1b\\" + const middle = "\x1b_Gm=1;CCCC\x1b\\" + const last = "\x1b_Gm=0;BBBB\x1b\\" + for _, direct := range []bool{true, false} { + t.Run(fmt.Sprintf("direct=%v", direct), func(t *testing.T) { + for split := 1; split <= len(first+last); split++ { + var output bytes.Buffer + w := Writer{Output: &output, Direct: direct, ImageID: 100} + write := func(s string) { + t.Helper() + for at := 0; at < len(s); at += split { + if _, err := w.Write([]byte(s[at:min(at+split, len(s))])); err != nil { + t.Fatal(err) + } + } + } + wrap := func(s string) string { + if direct { + return s + } + return ansi.TmuxPassthrough(s) + } + previous := 0 + for frame := range 4 { + id := 100 + frame%2 + output.Reset() + write(first) + // The visible image's ID must not be replaced or deleted while + // Kitty waits for the rest of a frame from a slow terminal/SSH. + want := strings.Replace(first, ";", fmt.Sprintf(",i=%d;", id), 1) + if output.String() != wrap(want) { + t.Fatalf("split %d, frame %d: upload replaced the visible image", split, frame) + } + output.Reset() + write(middle) + if output.String() != wrap(middle) { + t.Fatalf("split %d, frame %d: deleted an image in the middle of a chunked upload", split, frame) + } + output.Reset() + write(last) + want = last + if previous != 0 { + want += DeleteImage(previous) + } + if output.String() != wrap(want) { + t.Fatalf("split %d, frame %d: previous frame not retired after completion", split, frame) + } + previous = id + } + output.Reset() + write("\x1b_Ga=d;\x1b\\") + if output.String() != wrap(DeleteImage(100)+DeleteImage(101)) { + t.Fatal("resize/exit did not clean up both owned images") + } + } + }) + } +} + +func TestSharedMemoryFramesCompleteWithoutContinuation(t *testing.T) { + var output bytes.Buffer + w := Writer{Output: &output, Direct: true, ImageID: 100} + const frame = "\x1b_Ga=T,t=s,f=24,s=2,v=1,m=1;L21wdg==\x1b\\" + for i := range 3 { + output.Reset() + if _, err := w.Write([]byte(frame)); err != nil { + t.Fatal(err) + } + want := strings.Replace(frame, ";", fmt.Sprintf(",i=%d;", 100+i%2), 1) + if i > 0 { + want += DeleteImage(100 + (i-1)%2) + } + if output.String() != want { + t.Fatal("shared-memory frame did not replace and retire its predecessor") + } + } +} diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index fcef778..d748e01 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -27,14 +27,15 @@ import ( // and renderer for the entire Run call, then restores the library view. // Its status is private: IPC events and progress reporting run on the same loop. type terminalPlayback struct { - mpv string - output config.VideoOutput - preference config.VideoOutput - status PlayerStatus - subtitles []ExternalSubtitle - stdin io.Reader - stdout io.Writer - report func(context.Context, PlayerStatus) + mpv string + output config.VideoOutput + preference config.VideoOutput + status PlayerStatus + subtitles []ExternalSubtitle + stdin io.Reader + stdout io.Writer + report func(context.Context, PlayerStatus) + sharedMemory bool } var _ tea.ExecCommand = (*terminalPlayback)(nil) @@ -87,6 +88,9 @@ func (p *terminalPlayback) SetStderr(io.Writer) {} func (p *terminalPlayback) args(socket string) []string { args := []string{ + // Terminal renderers scale every frame in software. mpv's default + // high-quality scaler is needlessly expensive for terminal video. + "--profile=sw-fast", "--idle=once", "--keep-open=no", "--no-resume-playback", "--pause=no", "--loop-file=no", "--loop-playlist=no", "--force-window=no", "--terminal=yes", "--input-terminal=yes", @@ -96,8 +100,14 @@ func (p *terminalPlayback) args(socket string) []string { fmt.Sprintf("--start=%.3f", p.status.Time), } if p.output == config.VideoKitty { - // Escape-code transport also works over SSH; shared memory does not. - args = append(args, "--vo-kitty-use-shm=no") + // Only use shared memory after the terminal proves it can read it. + // This avoids transferring megabytes per frame through local tmux, + // while retaining the portable transport over SSH. + transport := "no" + if p.sharedMemory { + transport = "yes" + } + args = append(args, "--vo-kitty-use-shm="+transport) // Our control bar uses mpv's OSD compositor and IPC, independently // of the Lua OSC and its window-system mouse events. args = append(args, "--osc=no") @@ -113,6 +123,7 @@ func (p *terminalPlayback) Run() error { } if input, ok := p.stdin.(*os.File); ok { caps := termgraphics.Probe(input, p.stdout) + p.sharedMemory = caps.KittySharedMemory if p.preference != config.VideoWindow { var err error p.mpv, p.output, err = terminalOutput(context.Background(), p.preference, os.Getenv) @@ -169,9 +180,9 @@ func (p *terminalPlayback) Run() error { if p.output == config.VideoKitty { controls = &terminalControls{focus: -1, hover: -1, speed: 1, subtitles: true} p.status.Volume = 100 - graphics = &termgraphics.Writer{Output: p.stdout, Direct: !termgraphics.InTmux(), ImageID: (1 << 30) + rand.IntN(1<<20)} + graphics = &termgraphics.Writer{Output: p.stdout, Direct: !termgraphics.InTmux(), ImageID: (1 << 30) + 2*rand.IntN(1<<19)} cmd.Stdout = graphics - defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap(termgraphics.DeleteImage(graphics.ImageID))) }() + defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap(graphics.DeleteImages())) }() } // Our wrapper handles actual tmux panes. Also remove stale tmux variables // from direct Kitty windows so mpv cannot wrap their graphics by mistake. diff --git a/internal/tui/player/video_output_test.go b/internal/tui/player/video_output_test.go index d112f47..b528859 100644 --- a/internal/tui/player/video_output_test.go +++ b/internal/tui/player/video_output_test.go @@ -1,12 +1,30 @@ package player import ( + "slices" "testing" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/termgraphics" ) +func TestKittySharedMemoryRequiresSuccessfulProbe(t *testing.T) { + for _, local := range []bool{false, true} { + p := terminalPlayback{output: config.VideoKitty, sharedMemory: local} + want, unwanted := "--vo-kitty-use-shm=no", "--vo-kitty-use-shm=yes" + if local { + want, unwanted = unwanted, want + } + args := p.args("ipc") + if !slices.Contains(args, want) || slices.Contains(args, unwanted) { + t.Fatalf("sharedMemory=%v: unsafe transport arguments %v", local, args) + } + if !slices.Contains(args, "--profile=sw-fast") { + t.Fatal("missing software-rendering performance profile") + } + } +} + func TestSelectTerminalOutput(t *testing.T) { t.Parallel() const all = "Available video outputs:\n kitty Kitty graphics\n sixel Sixel graphics\n tct true-color terminals\n" From 7d1225abdb30bf9bd6817fe16c051ab7c793ce1e Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:23:55 -0400 Subject: [PATCH 16/21] feat: add Friends Libraries browsing and search --- README.md | 7 + cmd/auth.go | 81 +----- cmd/server.go | 23 +- docs/content/cli/plexctl_server.md | 2 +- docs/content/cli/plexctl_server_discover.md | 2 +- docs/content/index.mdx | 1 + docs/content/tui.mdx | 18 ++ internal/commands/wrapper.go | 98 +++---- internal/config/auth.go | 41 ++- internal/config/auth_test.go | 27 ++ internal/config/config.go | 37 ++- internal/config/server_target.go | 47 ++++ internal/plex/client.go | 33 ++- internal/plex/loader.go | 4 +- internal/plex/media.go | 30 +-- internal/plex/servers.go | 142 ++++++++++ internal/plex/servers_test.go | 146 +++++++++++ internal/presenters/server.go | 16 +- internal/search/index.go | 19 +- internal/search/live.go | 2 +- internal/tui/artwork/artwork.go | 5 +- internal/tui/friends_test.go | 72 +++++ internal/tui/player/actions.go | 31 +-- internal/tui/player/actions_test.go | 48 ++++ internal/tui/player/manager.go | 26 +- internal/tui/player/player_state.go | 6 +- internal/tui/player/terminal.go | 5 +- internal/tui/tab_manager.go | 10 + internal/tui/tui.go | 62 +++-- internal/tui/view/detail/base.go | 36 ++- internal/tui/view/detail/base_test.go | 34 +++ internal/tui/view/detail/detail_shared.go | 26 +- internal/tui/view/detail/episode_detail.go | 28 +- internal/tui/view/detail/manager.go | 5 + internal/tui/view/detail/movie_detail.go | 25 +- internal/tui/view/detail/music_detail.go | 24 +- internal/tui/view/detail/season_detail.go | 38 ++- internal/tui/view/detail/show_detail.go | 36 ++- internal/tui/view/friends.go | 275 ++++++++++++++++++++ internal/tui/view/friends_test.go | 107 ++++++++ internal/tui/view/media.go | 40 +-- internal/tui/widget/playback/playback.go | 11 +- internal/tui/widget/poster/grid.go | 15 +- internal/tui/widget/poster/item.go | 17 +- internal/tui/widget/poster/list.go | 23 +- internal/tui/widget/resume/resume.go | 7 +- internal/tui/widget/search/overlay.go | 19 +- internal/ui/types.go | 10 + 48 files changed, 1456 insertions(+), 361 deletions(-) create mode 100644 internal/config/server_target.go create mode 100644 internal/plex/servers.go create mode 100644 internal/plex/servers_test.go create mode 100644 internal/tui/friends_test.go create mode 100644 internal/tui/player/actions_test.go create mode 100644 internal/tui/view/detail/base_test.go create mode 100644 internal/tui/view/friends.go create mode 100644 internal/tui/view/friends_test.go diff --git a/README.md b/README.md index 435a5fa..9a43a5d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Pull requests run these checks and `govulncheck` in GitHub Actions. ## Features - **Interactive TUI**: Navigate your Plex library with ease. +- **Friends Libraries**: Browse and search libraries shared with your Plex account from the sidebar. - **Fuzzy Search**: Find exactly what you're looking for instantly. - **Cross-Platform**: Supports macOS, Linux, and Windows. - **Flexible Output**: CLI commands support table, JSON, YAML, and CSV formats. @@ -84,6 +85,12 @@ Pull requests run these checks and `govulncheck` in GitHub Actions. ### TUI (Interactive) Simply run `plexctl` to launch the interactive terminal interface. +Choose your own Plex server during setup. **Friends Libraries → Browse** in the +left sidebar discovers servers shared with your current Plex user. Open a friend, +then a library to use the same poster/list views, details, and playback controls. +Press **/** to search that friend's server, **Esc** to go back, and **r** on the +friends list to refresh invitations. Your own library tabs remain available. + Press **v** on a movie or episode to choose **Kitty**, **Sixel**, or **Text** playback. **Ctrl+P** starts terminal playback using your saved preference. Kitty video includes a visible control bar: play/pause, a draggable seek bar, diff --git a/cmd/auth.go b/cmd/auth.go index 4246f6d..454b074 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -2,14 +2,12 @@ package cmd import ( "fmt" - "strings" "github.com/BrenekH/go-plexauth" - "github.com/LukeHagar/plexgo/models/operations" "github.com/pkg/browser" "github.com/spf13/cobra" + "github.com/ygelfand/plexctl/internal/commands" "github.com/ygelfand/plexctl/internal/config" - "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/ui" ) @@ -59,78 +57,13 @@ var loginCmd = &cobra.Command{ cfg.Token = token ui.RenderSuccess("Successfully authenticated!") - // Discover servers - fmt.Println("\nDiscovering available servers...") - client, err := plex.NewClient() - if err != nil { - return fmt.Errorf("failed to initialize client for discovery: %w", err) - } - - res, err := client.SDK.Plex.GetServerResources(cmd.Context(), operations.GetServerResourcesRequest{ - IncludeHTTPS: operations.IncludeHTTPSTrue.ToPointer(), - IncludeIPv6: operations.IncludeIPv6True.ToPointer(), - IncludeRelay: operations.IncludeRelayTrue.ToPointer(), - }) - if err != nil { - return fmt.Errorf("failed to discover servers: %w", err) - } - - var options []struct{ Title, Desc, Value, ID string } - for _, device := range res.PlexDevices { - if strings.Contains(device.Provides, "server") { - for _, conn := range device.Connections { - if !conn.Relay { - desc := conn.URI - if conn.Local { - desc += " (Local)" - } - options = append(options, struct{ Title, Desc, Value, ID string }{ - Title: device.Name, - Desc: desc, - Value: conn.URI, - ID: device.ClientIdentifier, - }) - } - } - } - } - - if len(options) == 0 { - cfg.Logger.Warn("No servers discovered on plex.tv") - } else if len(options) == 1 { - opt := options[0] - cfg.AddServer(opt.ID, config.Server{Name: opt.Title, URL: opt.Value}, true) - fmt.Printf("Auto-selected only available server: %s (%s)\n", opt.Title, opt.Value) - } else { - var uiOptions []struct{ Title, Desc, Value string } - for _, o := range options { - uiOptions = append(uiOptions, struct{ Title, Desc, Value string }{ - Title: o.Title, - Desc: o.Desc, - Value: o.Value, - }) - } - - choice, err := ui.SelectOption("Select a Plex Server to use", uiOptions) - if err != nil { - return fmt.Errorf("failed to select server: %w", err) - } - - var selectedID, selectedName string - for _, opt := range options { - if opt.Value == choice { - selectedID = opt.ID - selectedName = opt.Title - break - } - } - - cfg.AddServer(selectedID, config.Server{Name: selectedName, URL: choice}, true) - fmt.Printf("Selected server: %s (%s)\n", selectedName, choice) - } - + cfg.HomeUser = config.HomeUser{} if err := cfg.Save(); err != nil { - return fmt.Errorf("failed to save configuration: %w", err) + return fmt.Errorf("save authentication: %w", err) + } + fmt.Println("\nDiscovering your Plex servers...") + if err := commands.DiscoverAndSelectServer(cmd.Context()); err != nil { + return err } fmt.Printf("\nToken and configuration saved to %s\n", cfg.ConfigPath) diff --git a/cmd/server.go b/cmd/server.go index f09343f..3cdc86d 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" - "github.com/LukeHagar/plexgo/models/operations" "github.com/spf13/cobra" "github.com/ygelfand/plexctl/internal/commands" "github.com/ygelfand/plexctl/internal/config" @@ -50,27 +49,20 @@ var serverListCmd = &cobra.Command{ ui.AnnotationSkipServerCheck: "true", }, RunE: commands.RunWithClient(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { - slog.Debug("SDK: Fetching server resources from plex.tv") - res, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{ - IncludeHTTPS: operations.IncludeHTTPSTrue.ToPointer(), - IncludeIPv6: operations.IncludeIPv6True.ToPointer(), - IncludeRelay: operations.IncludeRelayTrue.ToPointer(), - }) + devices, err := plex.DiscoverServers(ctx) if err != nil { - slog.Error("SDK: Failed to list servers", "error", err) - return fmt.Errorf("failed to list servers: %w", err) + return err } - slog.Debug("SDK: Received server resources", "count", len(res.PlexDevices)) return commands.Print(&presenters.ServerListPresenter{ - Devices: res.PlexDevices, + Devices: devices, }, opts) }), } var serverDiscoverCmd = &cobra.Command{ Use: "discover", - Short: "Interactive discovery and selection of a Plex server", + Short: "Discover and select your own Plex server", Annotations: map[string]string{ ui.AnnotationSkipServerCheck: "true", }, @@ -81,9 +73,10 @@ var serverDiscoverCmd = &cobra.Command{ } var serverUseCmd = &cobra.Command{ - Use: "use [name_or_id]", - Short: "Switch to a different configured server", - Args: cobra.ExactArgs(1), + Use: "use [name_or_id]", + Short: "Switch to a different configured server", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() slog.Debug("Config: Switching default server", "target", args[0]) diff --git a/docs/content/cli/plexctl_server.md b/docs/content/cli/plexctl_server.md index 7634a85..2a82414 100644 --- a/docs/content/cli/plexctl_server.md +++ b/docs/content/cli/plexctl_server.md @@ -22,7 +22,7 @@ Manage Plex Server ### SEE ALSO * [plexctl](plexctl.md) - A robust CLI for managing your Plex Media Server -* [plexctl server discover](plexctl_server_discover.md) - Interactive discovery and selection of a Plex server +* [plexctl server discover](plexctl_server_discover.md) - Discover and select your own Plex server * [plexctl server list](plexctl_server_list.md) - List all available Plex servers from plex.tv * [plexctl server status](plexctl_server_status.md) - Get server status * [plexctl server use](plexctl_server_use.md) - Switch to a different configured server diff --git a/docs/content/cli/plexctl_server_discover.md b/docs/content/cli/plexctl_server_discover.md index 9cce14a..6eb1679 100644 --- a/docs/content/cli/plexctl_server_discover.md +++ b/docs/content/cli/plexctl_server_discover.md @@ -1,6 +1,6 @@ ## plexctl server discover -Interactive discovery and selection of a Plex server +Discover and select your own Plex server ``` plexctl server discover [flags] diff --git a/docs/content/index.mdx b/docs/content/index.mdx index d1452e5..5c67e25 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -15,6 +15,7 @@ plexctl is designed for users who want a powerful, terminal-based way to interac ## Key Features - **🚀 Interactive TUI**: Navigate libraries, browse media, and manage sessions in a full-screen terminal app. +- **Friends Libraries**: Browse and search your friends' shared libraries from the left sidebar while keeping your own server selected. - **🔍 Fuzzy Search**: Instantly find movies, shows, or episodes with global fuzzy search. - **🎬 Integrated Playback**: Launch media directly in `mpv` with full control. - **⚙️ Deep Configuration**: Manage library visibility, custom icons, and theme preferences. diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index fa35f3e..92921f8 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -66,6 +66,24 @@ their own input. ## Features +### Friends Libraries + +Select your own server during setup. The **Friends Libraries → Browse** section +in the left navigation automatically discovers servers shared with your current +Plex account or Home profile. Select a friend to see the libraries they have +granted you access to, then open a library to browse its posters or list. + +The usual controls work here: **Enter** for details, **V** to switch views, +**g** or the alphabet rail to jump, and **p**, **Ctrl+P**, or **v** to play. +Press **/** after choosing a friend to search all libraries available on that +server. **Esc** returns to its libraries, then to the friends list. Your own +library tabs and default server stay unchanged. + +Press **r** on the friends list to refresh after accepting a Plex library +invitation. An unavailable server shows an error with retry controls. Only +libraries shared with the current Plex user are listed; switching Home profiles +refreshes this access. Plex Relay is tried when direct connections fail. + ### Fuzzy Search Press `/` to search artists, albums, tracks, movies, and TV. Local fuzzy matches appear immediately; Plex server results follow as you type. Server search covers diff --git a/internal/commands/wrapper.go b/internal/commands/wrapper.go index 2eefa45..38b2120 100644 --- a/internal/commands/wrapper.go +++ b/internal/commands/wrapper.go @@ -3,9 +3,7 @@ package commands import ( "context" "fmt" - "strings" - "github.com/LukeHagar/plexgo/models/operations" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/ygelfand/plexctl/internal/config" @@ -60,77 +58,49 @@ func EnsureActiveServer(ctx context.Context) error { // DiscoverAndSelectServer triggers the interactive server discovery flow func DiscoverAndSelectServer(ctx context.Context) error { - client, err := plex.NewClient() + servers, err := plex.DiscoverServers(ctx) if err != nil { return err } - - res, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{ - IncludeHTTPS: operations.IncludeHTTPSTrue.ToPointer(), - IncludeIPv6: operations.IncludeIPv6True.ToPointer(), - IncludeRelay: operations.IncludeRelayTrue.ToPointer(), - }) - if err != nil { - return fmt.Errorf("failed to discover servers: %w", err) - } - - var options []struct{ Title, Desc, Value, ID string } - for _, device := range res.PlexDevices { - if strings.Contains(device.Provides, "server") { - for _, conn := range device.Connections { - if !conn.Relay { - desc := conn.URI - if conn.Local { - desc += " (Local)" - } - options = append(options, struct{ Title, Desc, Value, ID string }{ - Title: device.Name, - Desc: desc, - Value: conn.URI, - ID: device.ClientIdentifier, - }) - } - } + // Initial selection is for the user's own server. Shared servers are + // discovered independently under Friends Libraries in the TUI. + var owned []struct{ Title, Desc, Value string } + for _, device := range servers { + if device.Owned { + owned = append(owned, struct{ Title, Desc, Value string }{device.Name, "Owned", device.ClientIdentifier}) } } - - if len(options) == 0 { - return fmt.Errorf("no servers discovered on plex.tv") - } - - // Adapt options for UI selector - var uiOptions []struct{ Title, Desc, Value string } - for _, o := range options { - uiOptions = append(uiOptions, struct{ Title, Desc, Value string }{ - Title: o.Title, - Desc: o.Desc, - Value: o.Value, - }) - } - - choice, err := ui.SelectOption("Select a Plex Server to use", uiOptions) - if err != nil { - return fmt.Errorf("failed to select server: %w", err) + if len(owned) == 0 { + return fmt.Errorf("no owned Plex servers discovered for this user") } - - // Find the selected option to get the ID and Name - var selectedID, selectedName string - for _, opt := range options { - if opt.Value == choice { - selectedID = opt.ID - selectedName = opt.Title - break + choice := owned[0].Value + if len(owned) > 1 { + choice, err = ui.SelectOption("Select your Plex server", owned) + if err != nil { + return err } } - - cfg := config.Get() - cfg.AddServer(selectedID, config.Server{Name: selectedName, URL: choice}, true) - if err := cfg.Save(); err != nil { - return fmt.Errorf("failed to save configuration: %w", err) + for _, device := range servers { + if device.ClientIdentifier != choice { + continue + } + target, err := plex.ConnectServer(ctx, device) + if err != nil { + return err + } + cfg := config.Get() + server := cfg.Servers[target.ID] + server.Name, server.URL = target.Name, target.URL + server.AccessToken, server.TokenSource = target.Token, cfg.TokenSource() + server.Shared = target.Shared + cfg.AddServer(target.ID, server, true) + if err := cfg.Save(); err != nil { + return fmt.Errorf("save configuration: %w", err) + } + ui.RenderSuccess(fmt.Sprintf("Selected and saved server: %s", target.Name)) + return nil } - - ui.RenderSuccess(fmt.Sprintf("Selected and saved server: %s (%s)", selectedName, choice)) - return nil + return fmt.Errorf("selected server is no longer available") } // Print formats and prints data using the provided Presenter diff --git a/internal/config/auth.go b/internal/config/auth.go index 7e4d049..1507945 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -1,14 +1,49 @@ package config -import "os" +import ( + "crypto/sha256" + "fmt" + "os" +) + +// AccountToken is used for plex.tv discovery, never a server's resource token. +func (c *Config) AccountToken() string { + if token := os.Getenv("PLEXCTL_TOKEN"); token != "" { + return token + } + if c.HomeUser.AuthToken != "" { + return c.HomeUser.AuthToken + } + return c.Token +} + +// TokenSource binds discovered credentials to the account or Home profile that +// obtained them, without persisting a second copy of the account token. +func (c *Config) TokenSource() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(c.AccountToken()))) +} + +func (c *Config) HasServerToken() bool { + _, server, ok := c.GetActiveServer() + return ok && server.AccessToken != "" && server.TokenSource == c.TokenSource() +} // ServerToken selects the same credentials for metadata, artwork, and streams. func (c *Config) ServerToken() string { + if c.HasServerToken() { + return c.Servers[c.DefaultServer].AccessToken + } if token := os.Getenv("PLEXCTL_TOKEN"); token != "" { return token } - if c.HomeUser.AccessToken != "" { + if c.HomeUser.AccessToken != "" && (c.HomeUser.ServerID == "" || c.HomeUser.ServerID == c.DefaultServer) { return c.HomeUser.AccessToken } - return c.Token + return c.AccountToken() +} + +// MediaCacheNamespace prevents matching item IDs on different servers or +// libraries visible to another Plex profile from sharing cached results. +func (c *Config) MediaCacheNamespace() string { + return c.ServerTarget().CacheNamespace() } diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go index 46c512f..cd9ea38 100644 --- a/internal/config/auth_test.go +++ b/internal/config/auth_test.go @@ -18,3 +18,30 @@ func TestServerTokenPrecedence(t *testing.T) { t.Fatal("account token fallback failed") } } + +func TestResourceTokensStayWithTheirServerAndAccount(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := Config{Token: "account", DefaultServer: "mine", Servers: map[string]Server{"mine": {URL: "https://mine.invalid"}}} + source := cfg.TokenSource() + cfg.Servers["friend"] = Server{Name: "Friend", AccessToken: "shared-token", TokenSource: source, Shared: true} + cfg.HomeUser.AccessToken = "old-server-token" + if err := cfg.SetDefaultServer("friend"); err != nil { + t.Fatal(err) + } + if cfg.ServerToken() != "shared-token" || cfg.AccountToken() != "account" || cfg.HomeUser.AccessToken != "" { + t.Fatal("switching servers reused credentials or changed the account token") + } + namespace := cfg.MediaCacheNamespace() + cfg.HomeUser.AuthToken = "different-profile" + if cfg.HasServerToken() || cfg.ServerToken() != "different-profile" || cfg.MediaCacheNamespace() == namespace { + t.Fatal("a different Home profile reused the previous user's access or cache") + } + t.Setenv("PLEXCTL_TOKEN", "account") + if cfg.ServerToken() != "shared-token" { + t.Fatal("resource token for the environment account was not selected") + } + t.Setenv("PLEXCTL_TOKEN", "different-account") + if cfg.ServerToken() != "different-account" { + t.Fatal("resource token escaped its account scope") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6b6e65f..63e9e23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,14 +87,18 @@ type LibraryConfig struct { } type Server struct { - Name string `mapstructure:"name" yaml:"name"` - URL string `mapstructure:"url" yaml:"url"` - Libraries LibraryConfig `mapstructure:"libraries" yaml:"libraries"` + Name string `mapstructure:"name" yaml:"name"` + URL string `mapstructure:"url" yaml:"url"` + Shared bool `mapstructure:"shared" yaml:"shared,omitempty"` + AccessToken string `mapstructure:"access_token" yaml:"access_token,omitempty" json:"-"` + TokenSource string `mapstructure:"token_source" yaml:"token_source,omitempty" json:"-"` + Libraries LibraryConfig `mapstructure:"libraries" yaml:"libraries"` } type HomeUser struct { AuthToken string `mapstructure:"auth_token" yaml:"auth_token"` // V2 Switch User Token AccessToken string `mapstructure:"access_token" yaml:"access_token"` // Server-specific Access Token + ServerID string `mapstructure:"server_id" yaml:"server_id,omitempty"` } // Config holds the global configuration for plexctl @@ -254,25 +258,44 @@ func (c *Config) GetActiveServer() (string, Server, bool) { func (c *Config) SetDefaultServer(idOrName string) error { // Try ID first if _, ok := c.Servers[idOrName]; ok { - c.DefaultServer = idOrName + c.selectServer(idOrName) return nil } // Try Name + var matchedID string for id, srv := range c.Servers { if srv.Name == idOrName { - c.DefaultServer = id - return nil + if matchedID != "" { + return fmt.Errorf("multiple servers named %q; use the server ID", idOrName) + } + matchedID = id } } + if matchedID != "" { + c.selectServer(matchedID) + return nil + } return fmt.Errorf("server '%s' not found in configuration", idOrName) } // AddServer adds or updates a server configuration and optionally sets it as default func (c *Config) AddServer(id string, config Server, setAsDefault bool) { + if c.Servers == nil { + c.Servers = make(map[string]Server) + } c.Servers[id] = config if setAsDefault { - c.DefaultServer = id + c.selectServer(id) + } +} + +func (c *Config) selectServer(id string) { + if id != c.DefaultServer { + // Legacy Home credentials belong only to the previously selected server. + c.HomeUser.AccessToken = "" + c.HomeUser.ServerID = "" } + c.DefaultServer = id } diff --git a/internal/config/server_target.go b/internal/config/server_target.go new file mode 100644 index 0000000..1aa617a --- /dev/null +++ b/internal/config/server_target.go @@ -0,0 +1,47 @@ +package config + +import ( + "context" + "crypto/sha256" + "fmt" +) + +// ServerTarget is a snapshot of a connection. Views and playback retain it so +// visiting a friend's library never changes the configured default server. +type ServerTarget struct { + ID string + Name string + URL string + Token string `json:"-"` + Shared bool +} + +func (c *Config) ServerTarget() ServerTarget { + id, server, _ := c.GetActiveServer() + return ServerTarget{ID: id, Name: server.Name, URL: server.URL, Token: c.ServerToken(), Shared: server.Shared} +} + +// MediaServer supplies the current server for callers without an explicit target. +func MediaServer(targets ...ServerTarget) ServerTarget { + if len(targets) > 0 && targets[0].ID != "" { + return targets[0] + } + return Get().ServerTarget() +} + +func (s ServerTarget) CacheNamespace() string { + return fmt.Sprintf("%s/%x", s.ID, sha256.Sum256([]byte(s.Token))) +} + +type serverContextKey struct{} + +func (s ServerTarget) Context(ctx context.Context) context.Context { + return context.WithValue(ctx, serverContextKey{}, s) +} + +func ServerFromContext(ctx context.Context) ServerTarget { + if server, ok := ctx.Value(serverContextKey{}).(ServerTarget); ok { + return server + } + return Get().ServerTarget() +} diff --git a/internal/plex/client.go b/internal/plex/client.go index e48a6d3..fafcc14 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -60,20 +60,33 @@ func NewClient() (*Client, error) { return NewClientWithToken(config.Get().ServerToken()) } -// NewHomeUserClient specifically uses the V2 AuthToken (general user token) +// NewAccountClient uses the active Plex account or Home profile for plex.tv. +func NewAccountClient() (*Client, error) { + return NewClientWithToken(config.Get().AccountToken()) +} + +// NewHomeHomeUserClient specifically uses the general user token. func NewHomeHomeUserClient() (*Client, error) { - cfg := config.Get() - token := cfg.Token - if cfg.HomeUser.AuthToken != "" { - token = cfg.HomeUser.AuthToken - } - return NewClientWithToken(token) + return NewAccountClient() } func NewClientWithToken(token string) (*Client, error) { cfg := config.Get() - // 2. Resolve Server (if configured) _, serverCfg, hasServer := cfg.GetActiveServer() + serverURL := "" + if hasServer { + serverURL = serverCfg.URL + } + return newClient(token, serverURL) +} + +func NewClientForContext(ctx context.Context) (*Client, error) { + server := config.ServerFromContext(ctx) + return newClient(server.Token, server.URL) +} + +func newClient(token, serverURL string) (*Client, error) { + cfg := config.Get() if token == "" { return nil, fmt.Errorf("plex token not found. please login with 'plexctl login' or set PLEXCTL_TOKEN") @@ -98,8 +111,8 @@ func NewClientWithToken(token string) (*Client, error) { plexgo.WithVersion(config.Version), } - if hasServer && serverCfg.URL != "" { - opts = append(opts, plexgo.WithServerURL(serverCfg.URL)) + if serverURL != "" { + opts = append(opts, plexgo.WithServerURL(serverURL)) } return &Client{SDK: plexgo.New(opts...)}, nil diff --git a/internal/plex/loader.go b/internal/plex/loader.go index d888284..a655e56 100644 --- a/internal/plex/loader.go +++ b/internal/plex/loader.go @@ -70,7 +70,7 @@ func LoadData(ctx context.Context, updates chan<- any) { sendUpdate(0.2, "Fetching libraries list...") slog.Debug("Loader: Fetching sections from server", "server_id", serverID) var sectionsBody operations.GetSectionsResponseBody - err = cache.AutoCache(cm, serverID, nil, LibraryCacheTTL, §ionsBody, func() (*operations.GetSectionsResponseBody, error) { + err = cache.AutoCache(cm, cfg.MediaCacheNamespace(), nil, LibraryCacheTTL, §ionsBody, func() (*operations.GetSectionsResponseBody, error) { slog.Log(context.Background(), config.LevelTrace, "Loader: Sections Cache MISS, calling SDK") res, err := client.SDK.Library.GetSections(ctx) if err != nil { @@ -110,7 +110,7 @@ func LoadData(ctx context.Context, updates chan<- any) { var libInfo LibraryInfo req := operations.ListContentRequest{SectionID: key} - err = cache.AutoCache(cm, serverID, req, LibraryCacheTTL, &libInfo, func() (*LibraryInfo, error) { + err = cache.AutoCache(cm, cfg.MediaCacheNamespace(), req, LibraryCacheTTL, &libInfo, func() (*LibraryInfo, error) { slog.Log(context.Background(), config.LevelTrace, "Loader: Content Cache MISS", "library", title) count := 0 contentRes, err := client.SDK.Content.ListContent(ctx, req) diff --git a/internal/plex/media.go b/internal/plex/media.go index 5a78d6c..5810cd0 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -57,8 +57,8 @@ func SetCachedPoster(ratingKey string, width int, view string) { // GetMetadata retrieves full metadata for an item by its rating key. If force is true, cache is bypassed. func GetMetadata(ctx context.Context, ratingKey string, force bool) (*components.Metadata, error) { cfg := config.Get() - serverID, _, ok := cfg.GetActiveServer() - if !ok { + server := config.ServerFromContext(ctx) + if server.URL == "" { return nil, fmt.Errorf("no active server") } @@ -67,7 +67,7 @@ func GetMetadata(ctx context.Context, ratingKey string, force bool) (*components return nil, err } - client, err := NewClient() + client, err := NewClientForContext(ctx) if err != nil { return nil, err } @@ -83,7 +83,7 @@ func GetMetadata(ctx context.Context, ratingKey string, force bool) (*components ttl = 0 } - err = cache.AutoCache(cm, serverID, req, ttl, &body, func() (*components.MediaContainerWithMetadata, error) { + err = cache.AutoCache(cm, server.CacheNamespace(), req, ttl, &body, func() (*components.MediaContainerWithMetadata, error) { res, err := client.SDK.Content.GetMetadataItem(ctx, req) if err != nil { return nil, err @@ -111,8 +111,8 @@ func ptr[T any](v T) *T { // GetChildren retrieves children of an item (e.g. seasons of a show, or episodes of a season) func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, error) { cfg := config.Get() - serverID, serverCfg, ok := cfg.GetActiveServer() - if !ok { + server := config.ServerFromContext(ctx) + if server.URL == "" { return nil, fmt.Errorf("no active server") } @@ -121,18 +121,18 @@ func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, return nil, err } - cacheKey := fmt.Sprintf("%s/children/%s", serverID, ratingKey) + cacheKey := fmt.Sprintf("%s/children/%s", server.CacheNamespace(), ratingKey) var body components.MediaContainerWithMetadata if err := cm.Get(cacheKey, &body); err == nil { return body.MediaContainer.Metadata, nil } - url := fmt.Sprintf("%s/library/metadata/%s/children", serverCfg.URL, ratingKey) + url := fmt.Sprintf("%s/library/metadata/%s/children", server.URL, ratingKey) separator := "?" if strings.Contains(url, "?") { separator = "&" } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(cfg.ServerToken())) + url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(server.Token)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -173,8 +173,8 @@ func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, // GetImage retrieves image data (thumb/poster) and caches it func GetImage(ctx context.Context, path string) ([]byte, error) { cfg := config.Get() - serverID, serverCfg, ok := cfg.GetActiveServer() - if !ok { + server := config.ServerFromContext(ctx) + if server.URL == "" { return nil, fmt.Errorf("no active server") } @@ -183,18 +183,18 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { return nil, err } - cacheKey := fmt.Sprintf("%s/img/%s", serverID, path) + cacheKey := fmt.Sprintf("%s/img/%s", server.CacheNamespace(), path) var data []byte if err := cm.Get(cacheKey, &data); err == nil { return data, nil } - url := fmt.Sprintf("%s%s", serverCfg.URL, path) + url := fmt.Sprintf("%s%s", server.URL, path) separator := "?" if strings.Contains(url, "?") { separator = "&" } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(cfg.ServerToken())) + url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(server.Token)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -225,7 +225,7 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { // GetHomeHubs retrieves all promoted hubs for the home screen func GetHomeHubs(ctx context.Context) ([]components.Hub, error) { - client, err := NewClient() + client, err := NewClientForContext(ctx) if err != nil { return nil, err } diff --git a/internal/plex/servers.go b/internal/plex/servers.go new file mode 100644 index 0000000..05e431c --- /dev/null +++ b/internal/plex/servers.go @@ -0,0 +1,142 @@ +package plex + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/config" +) + +// DiscoverServers includes servers shared with the active Plex account/profile. +func DiscoverServers(ctx context.Context) ([]components.PlexDevice, error) { + client, err := NewAccountClient() + if err != nil { + return nil, err + } + res, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{ + IncludeHTTPS: operations.IncludeHTTPSTrue.ToPointer(), + IncludeIPv6: operations.IncludeIPv6True.ToPointer(), + IncludeRelay: operations.IncludeRelayTrue.ToPointer(), + }) + if err != nil { + return nil, fmt.Errorf("discover Plex servers: %w", err) + } + return mediaServers(res.PlexDevices), nil +} + +func mediaServers(devices []components.PlexDevice) []components.PlexDevice { + var servers []components.PlexDevice + for _, device := range devices { + if slices.Contains(strings.Split(device.Provides, ","), "server") { + servers = append(servers, device) + } + } + slices.SortStableFunc(servers, func(a, b components.PlexDevice) int { + if a.Owned != b.Owned { + if a.Owned { + return -1 + } + return 1 + } + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return servers +} + +// ConnectServer tries advertised direct connections before Plex Relay. Only the +// resource's token is sent; an account token must never be sent to a friend's PMS. +func ConnectServer(ctx context.Context, device components.PlexDevice) (config.ServerTarget, error) { + if device.ClientIdentifier == "" { + return config.ServerTarget{}, fmt.Errorf("missing Plex server identity") + } + if device.AccessToken == "" { + return config.ServerTarget{}, fmt.Errorf("no access token for %s; refresh your Plex login or library invitation", device.Name) + } + connections := slices.Clone(device.Connections) + slices.SortStableFunc(connections, func(a, b components.Connections) int { + return connectionRank(a, device.Owned) - connectionRank(b, device.Owned) + }) + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + for _, conn := range connections { + u, err := url.Parse(conn.URI) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || + (u.Scheme != "https" && u.Scheme != "http") || (device.HTTPSRequired && u.Scheme != "https") { + continue + } + target := config.ServerTarget{ID: device.ClientIdentifier, Name: device.Name, URL: strings.TrimRight(conn.URI, "/"), Token: device.AccessToken, Shared: !device.Owned} + if probeServer(ctx, client, target) == nil { + return target, nil + } + if ctx.Err() != nil { + break + } + } + return config.ServerTarget{}, fmt.Errorf("cannot reach %s or access its libraries; check that the server is online and sharing is still enabled", device.Name) +} + +func connectionRank(conn components.Connections, owned bool) int { + rank := 0 + if conn.Relay { + rank += 8 + } + if conn.Local != owned { + rank += 4 + } + if !strings.HasPrefix(conn.URI, "https://") { + rank++ + } + return rank +} + +func probeServer(ctx context.Context, client *http.Client, target config.ServerTarget) error { + // Checking identity avoids mistaking a friend's private LAN address for a + // different Plex server at the same address on the current user's network. + for _, path := range []string{"/identity", "/library/sections"} { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.URL+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Plex-Token", target.Token) + req.Header.Set("X-Plex-Client-Identifier", config.ClientIdentifier()) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("connection failed") + } + var body struct { + MediaContainer *struct { + MachineIdentifier string `json:"machineIdentifier"` + } `json:"MediaContainer"` + } + err = json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || err != nil || body.MediaContainer == nil { + return fmt.Errorf("server did not allow library access") + } + if path == "/identity" && body.MediaContainer.MachineIdentifier != target.ID { + return fmt.Errorf("server identity mismatch") + } + } + return nil +} + +func ServerDescription(device components.PlexDevice) string { + if device.Owned { + return "Owned" + } + if device.SourceTitle != nil && *device.SourceTitle != "" { + return "Shared by " + *device.SourceTitle + } + return "Shared with you" +} diff --git a/internal/plex/servers_test.go b/internal/plex/servers_test.go new file mode 100644 index 0000000..4a76342 --- /dev/null +++ b/internal/plex/servers_test.go @@ -0,0 +1,146 @@ +package plex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/config" +) + +type resourceTransport func(*http.Request) (*http.Response, error) + +func (f resourceTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestDiscoveryUsesAccountTokenAndIncludesFriends(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + oldTransport := http.DefaultTransport + t.Cleanup(func() { *cfg = old; http.DefaultTransport = oldTransport }) + cfg.Token = "account" + cfg.HomeUser = config.HomeUser{AuthToken: "profile-account", AccessToken: "server-only"} + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "profile-account" { + t.Error("discovery used a server token") + } + if r.URL.Query().Get("includeRelay") != "1" { + t.Error("relay discovery was disabled") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]components.PlexDevice{ + {Name: "Friend", ClientIdentifier: "friend", Provides: "server", AccessToken: "shared"}, + {Name: "Mine", ClientIdentifier: "mine", Provides: "server", Owned: true}, + {Name: "Player", Provides: "player"}, + }) + })) + defer fixture.Close() + http.DefaultTransport = resourceTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "plex.tv" { + t.Fatalf("unexpected discovery host %s", r.URL.Host) + } + clone := r.Clone(r.Context()) + clone.URL.Scheme, clone.URL.Host = "http", strings.TrimPrefix(fixture.URL, "http://") + return oldTransport.RoundTrip(clone) + }) + servers, err := DiscoverServers(context.Background()) + if err != nil || len(servers) != 2 || !servers[0].Owned || servers[1].AccessToken != "shared" { + t.Fatalf("discover owned and shared servers: count=%d, error=%v", len(servers), err) + } +} + +func TestConnectSharedServerFallsBackToRelayWithoutAccountToken(t *testing.T) { + var attempts []string + makeServer := func(name, identity string, status int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts = append(attempts, name) + if r.Header.Get("X-Plex-Token") != "resource-token" { + t.Error("wrong shared-server credentials") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": map[string]any{"machineIdentifier": identity}}) + })) + } + wrong := makeServer("wrong", "another-server", 200) + defer wrong.Close() + denied := makeServer("denied", "friend", 401) + defer denied.Close() + relay := makeServer("relay", "friend", 200) + defer relay.Close() + device := components.PlexDevice{Name: "Friend", ClientIdentifier: "friend", AccessToken: "resource-token", Connections: []components.Connections{ + {URI: relay.URL, Relay: true}, {URI: wrong.URL}, {URI: denied.URL}, + }} + target, err := ConnectServer(context.Background(), device) + if err != nil || target.URL != relay.URL || !target.Shared { + t.Fatalf("relay fallback: %v", err) + } + if strings.Join(attempts, ",") != "wrong,denied,relay,relay" { + t.Fatalf("unexpected connection attempts: %v", attempts) + } + device.AccessToken = "" + if _, err := ConnectServer(context.Background(), device); err == nil { + t.Fatal("accepted missing share token") + } + device.AccessToken, device.HTTPSRequired = "resource-token", true + if _, err := ConnectServer(context.Background(), device); err == nil { + t.Fatal("downgraded a secure-only server") + } +} + +func TestSharedMetadataAndArtworkDoNotChangeOrReuseOwnedServer(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.Token, cfg.HomeUser = "own-token", config.HomeUser{} + cfg.DefaultServer, cfg.Servers, cfg.NoCache = "owned-isolation", map[string]config.Server{"owned-isolation": {URL: "https://owned.invalid"}}, false + cfg.CacheDir = t.TempDir() + newServer := func(title, token string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Plex-Token") + if got == "" { + got = r.URL.Query().Get("X-Plex-Token") + } + if got != token { + t.Error("request used another server's token") + http.Error(w, "denied", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/art" { + _, _ = w.Write([]byte(title)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": map[string]any{"Metadata": []any{map[string]any{"ratingKey": "1", "title": title, "type": "movie"}}}}) + })) + } + a, b := newServer("First friend", "first+&token"), newServer("Second friend", "second-token") + defer a.Close() + defer b.Close() + for _, target := range []config.ServerTarget{ + {ID: "shared-a-isolation", URL: a.URL, Token: "first+&token", Name: "First friend"}, + {ID: "shared-b-isolation", URL: b.URL, Token: "second-token", Name: "Second friend"}, + } { + ctx := target.Context(context.Background()) + meta, err := GetMetadata(ctx, "1", false) + if err != nil || meta.Title != target.Name { + t.Fatalf("metadata crossed servers: %v", err) + } + children, err := GetChildren(ctx, "1") + if err != nil || len(children) != 1 || children[0].Title != target.Name { + t.Fatalf("children crossed servers: %v", err) + } + image, err := GetImage(ctx, "/art") + if err != nil || string(image) != target.Name { + t.Fatalf("artwork crossed servers: %v", err) + } + } + if cfg.DefaultServer != "owned-isolation" || cfg.ServerToken() != "own-token" { + t.Fatal("shared requests changed the owned server") + } +} diff --git a/internal/presenters/server.go b/internal/presenters/server.go index db1b97f..89ff34c 100644 --- a/internal/presenters/server.go +++ b/internal/presenters/server.go @@ -20,7 +20,7 @@ func (p *ServerListPresenter) Title() string { } func (p *ServerListPresenter) Headers() []string { - return []string{"ID", "NAME", "PRODUCT", "VERSION", "PLATFORM", "LAST SEEN"} + return []string{"ID", "NAME", "ACCESS", "PRODUCT", "VERSION", "PLATFORM", "LAST SEEN"} } func (p *ServerListPresenter) Rows() [][]string { @@ -29,6 +29,7 @@ func (p *ServerListPresenter) Rows() [][]string { rows = append(rows, []string{ d.ClientIdentifier, d.Name, + serverAccess(d), d.Product, d.ProductVersion, ui.PtrToString(d.Platform), @@ -39,7 +40,18 @@ func (p *ServerListPresenter) Rows() [][]string { } func (p *ServerListPresenter) Raw() any { - return p.Devices + devices := append([]components.PlexDevice(nil), p.Devices...) + for i := range devices { + devices[i].AccessToken = "" + } + return devices +} + +func serverAccess(d components.PlexDevice) string { + if d.Owned { + return "Owned" + } + return "Shared" } func (p *ServerListPresenter) SortableColumns() []string { diff --git a/internal/search/index.go b/internal/search/index.go index e382527..26782d4 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -65,15 +65,20 @@ func (idx *SearchIndex) Snapshot() ([]IndexEntry, time.Time) { } var ( - indexInstance *SearchIndex - indexOnce sync.Once + indexInstance *SearchIndex + indexMu sync.Mutex + indexNamespace string ) func GetIndex() *SearchIndex { - indexOnce.Do(func() { + indexMu.Lock() + defer indexMu.Unlock() + namespace := config.Get().MediaCacheNamespace() + if indexInstance == nil || indexNamespace != namespace { indexInstance = &SearchIndex{} + indexNamespace = namespace _ = indexInstance.Load() - }) + } return indexInstance } @@ -84,8 +89,7 @@ func (idx *SearchIndex) Load() error { return err } - serverID, _, _ := cfg.GetActiveServer() - key := fmt.Sprintf("%s/search_index", serverID) + key := fmt.Sprintf("%s/search_index", cfg.MediaCacheNamespace()) var data SearchIndex if err := cm.Get(key, &data); err == nil { @@ -105,8 +109,7 @@ func (idx *SearchIndex) Save() error { return err } - serverID, _, _ := cfg.GetActiveServer() - key := fmt.Sprintf("%s/search_index", serverID) + key := fmt.Sprintf("%s/search_index", cfg.MediaCacheNamespace()) idx.mu.RLock() defer idx.mu.RUnlock() diff --git a/internal/search/live.go b/internal/search/live.go index 006e5a2..0c43c31 100644 --- a/internal/search/live.go +++ b/internal/search/live.go @@ -16,7 +16,7 @@ func Live(ctx context.Context, query string) ([]IndexEntry, error) { if strings.TrimSpace(query) == "" { return nil, nil } - client, err := plex.NewClient() + client, err := plex.NewClientForContext(ctx) if err != nil { return nil, err } diff --git a/internal/tui/artwork/artwork.go b/internal/tui/artwork/artwork.go index 9571810..195a9d5 100644 --- a/internal/tui/artwork/artwork.go +++ b/internal/tui/artwork/artwork.go @@ -17,7 +17,6 @@ import ( "github.com/charmbracelet/x/ansi/kitty" gopixels "github.com/saran13raj/go-pixels" - "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/termgraphics" "golang.org/x/image/draw" @@ -59,8 +58,8 @@ func Open(output *os.File, enabled bool) (*Output, error) { } func key(ratingKey string, width int) string { - server, _, _ := config.Get().GetActiveServer() - return fmt.Sprintf("%s/%s/%d", server, ratingKey, width) + // Callers include the server and credential namespace in the artwork key. + return fmt.Sprintf("%s/%d", ratingKey, width) } func Cached(ratingKey string, width int) (string, bool) { diff --git a/internal/tui/friends_test.go b/internal/tui/friends_test.go new file mode 100644 index 0000000..f32ff8d --- /dev/null +++ b/internal/tui/friends_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/view" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestFriendsSidebarAndPageRouting(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.DefaultServer, cfg.Token, cfg.DefaultViewMode = "mine", "owned", config.ViewModeList + cfg.HomeUser = config.HomeUser{} + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + cfg.Servers = map[string]config.Server{"mine": {Name: "Mine"}} + ui.GetLayout().Update(120, 40, false) + tm := NewTabManager(plex.LoaderResult{Libraries: []plex.LibraryInfo{{ID: "1", Title: "Own Movies"}}}, ui.PlexctlTheme, ui.SidebarWidth) + tm.navbar.Height = 40 + if !strings.Contains(strings.ToLower(tm.navbar.View()), "friends libraries") { + t.Fatal("missing Friends Libraries section") + } + friends := tm.tabModels[tm.friendsIdx].(*view.FriendsView) + target := config.ServerTarget{ID: "friend", Name: "Friend", Token: "shared", Shared: true} + friends.Update(view.FriendsLibrariesMsg{Server: target, Libraries: []plex.LibraryInfo{{ID: "1", Title: "Shared Movies"}}}) + friends.Update(ui.KeyPress("enter")) // Start a library request without making a network call. + // The global page request IDs are intentionally not assumed here. This + // verifies late artwork and target selection while switching active tabs. + c := &Controller{tabManager: tm, navigator: NewNavigator(ui.PlexctlTheme), theme: ui.PlexctlTheme} + c.tabManager.SetActive(tm.friendsIdx) + if c.mediaServer().ID != "friend" { + t.Fatal("friend selection lost its server") + } + c.tabManager.SetActive(1) + if c.mediaServer().ID != "mine" { + t.Fatal("owned tab kept the friend's server") + } + c.Update(poster.PosterLoadedMsg{ServerID: target.CacheNamespace(), RatingKey: "1", View: "friend artwork"}) + if cfg.DefaultServer != "mine" { + t.Fatal("friends changed default_server") + } + + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "shared" { + t.Error("friend page used wrong credentials") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[{"ratingKey":"1","title":"Shared Film","type":"movie"}]}}`)) + })) + defer endpoint.Close() + target.URL = endpoint.URL + friendView := view.NewMediaView("1", "Friend Movies", ui.PlexctlTheme, target) + owned := view.NewMediaView("1", "Own Movies", ui.PlexctlTheme, cfg.ServerTarget()) + tm.tabModels, tm.activeTabIdx = []tea.Model{owned, friendView}, 0 + owned.Init() + page := friendView.Init()() + c.Update(page) + if owned.GetSelectedMetadata() != nil { + t.Fatal("friend response populated the owned library") + } + if selected := friendView.GetSelectedMetadata(); selected == nil || selected.Title != "Shared Film" { + t.Fatal("late page was not routed to its friend's view") + } +} diff --git a/internal/tui/player/actions.go b/internal/tui/player/actions.go index 4f70b47..5d877a6 100644 --- a/internal/tui/player/actions.go +++ b/internal/tui/player/actions.go @@ -15,15 +15,14 @@ import ( "github.com/ygelfand/plexctl/internal/ui" ) -func PlayMedia(metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64) tea.Cmd { +func PlayMedia(metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) part := plex.PlayablePart(metadata) if part == nil { return func() tea.Msg { return fmt.Errorf("no playable media found") } } - cfg := config.Get() - _, serverCfg, ok := cfg.GetActiveServer() - if !ok { + if server.URL == "" { return func() tea.Msg { return fmt.Errorf("no active server") } } @@ -31,8 +30,8 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video if strings.Contains(part.Key, "?") { separator = "&" } - token := url.QueryEscape(cfg.ServerToken()) - playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, part.Key, separator, token) + token := url.QueryEscape(server.Token) + playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", server.URL, part.Key, separator, token) var subtitles []ExternalSubtitle for _, stream := range part.Stream { @@ -41,7 +40,7 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video if strings.Contains(stream.Key, "?") { sep = "&" } - subURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, stream.Key, sep, token) + subURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", server.URL, stream.Key, sep, token) lang := "" if stream.LanguageCode != nil { lang = *stream.LanguageCode @@ -68,13 +67,14 @@ func PlayMedia(metadata *components.Metadata, noReport bool, output config.Video title = "Trailer: " + title } - return GetPlayerManager().Play(playURL, title, rk, noReport, output, startOffset, subtitles) + return GetPlayerManager().Play(playURL, title, rk, noReport, output, startOffset, subtitles, server) } // FetchAndPlay handles the full playback logic: fetch full metadata, check for resume, then play -func FetchAndPlay(ratingKey string, output config.VideoOutput) tea.Cmd { +func FetchAndPlay(ratingKey string, output config.VideoOutput, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 30*time.Second) defer cancel() meta, err := plex.GetMetadata(ctx, ratingKey, true) if err != nil { @@ -86,13 +86,13 @@ func FetchAndPlay(ratingKey string, output config.VideoOutput) tea.Cmd { } if meta.ViewOffset != nil && *meta.ViewOffset > 0 { - return ui.ResumeChoiceMsg{ + return ui.ResumeChoiceMsg{Server: server, Metadata: meta, Output: output, } } - cmd := PlayMedia(meta, false, output, 0) + cmd := PlayMedia(meta, false, output, 0, server) if cmd != nil { return cmd() } @@ -101,7 +101,8 @@ func FetchAndPlay(ratingKey string, output config.VideoOutput) tea.Cmd { } // FetchAndPlayTrailer handles finding and playing the correct trailer for a media item -func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOutput) tea.Cmd { +func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOutput, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) return func() tea.Msg { targetKey := "" if parentMeta.PrimaryExtraKey != nil { @@ -127,12 +128,12 @@ func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOut slog.Debug("FetchAndPlayTrailer: Resolved trailer key", "parent", parentMeta.Title, "target", ratingKey) - meta, err := plex.GetMetadata(context.Background(), ratingKey, false) + meta, err := plex.GetMetadata(server.Context(context.Background()), ratingKey, false) if err != nil { return err } - cmd := PlayMedia(meta, true, output, 0) + cmd := PlayMedia(meta, true, output, 0, server) if cmd != nil { return cmd() } diff --git a/internal/tui/player/actions_test.go b/internal/tui/player/actions_test.go new file mode 100644 index 0000000..888d9ab --- /dev/null +++ b/internal/tui/player/actions_test.go @@ -0,0 +1,48 @@ +package player + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestSharedResumeAndProgressKeepOriginatingServer(t *testing.T) { + var reports int + friend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "friend-token" { + t.Error("playback used a different account or server token") + http.Error(w, "denied", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/:/timeline" { + reports++ + _, _ = w.Write([]byte(`{"MediaContainer":{}}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": map[string]any{"Metadata": []any{map[string]any{ + "ratingKey": "1", "title": "Shared Film", "type": "movie", "viewOffset": 5000, + "Media": []any{map[string]any{"Part": []any{map[string]any{"key": "/video"}}}}, + }}}}) + })) + defer friend.Close() + target := config.ServerTarget{ID: "friend", URL: friend.URL, Token: "friend-token", Shared: true} + msg := FetchAndPlay("1", config.VideoWindow, target)() + resume, ok := msg.(ui.ResumeChoiceMsg) + if !ok { + t.Fatalf("expected resume choice, got %T: %v", msg, msg) + } + if resume.Server != target || *resume.Metadata.ViewOffset != 5000 { + t.Fatal("resume lost the shared server or offset") + } + reportProgress(context.Background(), PlayerStatus{Server: resume.Server, Key: "1", Time: 6, Duration: 60, State: operations.StatePlaying}) + if reports != 1 { + t.Fatal("watch progress was not reported to the friend's server") + } +} diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index a081b98..07a8115 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -3,6 +3,7 @@ package player import ( "context" "fmt" + neturl "net/url" "os" "os/exec" "path/filepath" @@ -74,13 +75,14 @@ func (pm *PlayerManager) VerifyConnection() bool { func (pm *PlayerManager) Play( url, title, ratingKey string, noReport bool, output config.VideoOutput, - startOffset int64, subtitles []ExternalSubtitle, + startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget, ) tea.Cmd { + server := config.MediaServer(servers...) if output != config.VideoWindow && output != config.AudioTerminal { - return pm.playTerminal(url, title, ratingKey, noReport, output, startOffset, subtitles) + return pm.playTerminal(url, title, ratingKey, noReport, output, startOffset, subtitles, server) } return func() tea.Msg { - previous, err := pm.start(url, title, ratingKey, noReport, output, startOffset, subtitles) + previous, err := pm.start(url, title, ratingKey, noReport, output, startOffset, subtitles, server) if err != nil { return err } @@ -96,8 +98,9 @@ func (pm *PlayerManager) Play( func (pm *PlayerManager) start( url, title, ratingKey string, noReport bool, output config.VideoOutput, - startOffset int64, subtitles []ExternalSubtitle, + startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget, ) (PlayerStatus, error) { + server := config.MediaServer(servers...) pm.ioMu.Lock() defer pm.ioMu.Unlock() previous := pm.Status() @@ -123,13 +126,15 @@ func (pm *PlayerManager) start( _ = pm.conn.call("set_property", "pause", false) _ = pm.conn.call("set_property", "force-media-title", title) _ = pm.conn.call("set_property", "user-data/plex-rating-key", ratingKey) + _ = pm.conn.call("set_property", "user-data/plex-server-id", server.ID) + _ = pm.conn.call("set_property", "user-data/plex-server-url", server.URL) _ = pm.conn.call("set_property", "user-data/plex-title", title) _ = pm.conn.call("set_property", "user-data/plex-no-report", noReport) _ = pm.conn.call("set_property", "user-data/plex-audio-only", audioOnly) for _, sub := range subtitles { _ = pm.conn.call("sub-add", sub.URL, "auto", sub.Title, sub.Language) } - pm.setStatus(PlayerStatus{ + pm.setStatus(PlayerStatus{Server: server, Title: title, File: url, Key: ratingKey, NoReport: noReport, AudioOnly: audioOnly, Time: float64(startOffset) / 1000, State: operations.StatePlaying, Volume: previous.Volume, }) @@ -239,6 +244,14 @@ func (pm *PlayerManager) Reconnect() tea.Cmd { _ = conn.get("user-data/plex-no-report", &status.NoReport) _ = conn.get("user-data/plex-audio-only", &status.AudioOnly) _ = conn.get("path", &status.File) + _ = conn.get("user-data/plex-server-id", &status.Server.ID) + _ = conn.get("user-data/plex-server-url", &status.Server.URL) + if mediaURL, err := neturl.Parse(status.File); err == nil { + status.Server.Token = mediaURL.Query().Get("X-Plex-Token") + } + if status.Server.ID == "" || status.Server.URL == "" || status.Server.Token == "" { + status.NoReport = true // An older player cannot identify its originating server. + } pm.setStatus(status) pm.attach(conn) return nil @@ -420,7 +433,8 @@ func reportProgress(ctx context.Context, status PlayerStatus) { } ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - client, err := plex.NewClient() + ctx = config.MediaServer(status.Server).Context(ctx) + client, err := plex.NewClientForContext(ctx) if err != nil { return } diff --git a/internal/tui/player/player_state.go b/internal/tui/player/player_state.go index 5e7da7c..181bbf0 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -1,8 +1,12 @@ package player -import "github.com/LukeHagar/plexgo/models/operations" +import ( + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/config" +) type PlayerStatus struct { + Server config.ServerTarget Time float64 Duration float64 Title string diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index d748e01..da7347a 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -40,7 +40,8 @@ type terminalPlayback struct { var _ tea.ExecCommand = (*terminalPlayback)(nil) -func (pm *PlayerManager) playTerminal(url, title, ratingKey string, noReport bool, preference config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { +func (pm *PlayerManager) playTerminal(url, title, ratingKey string, noReport bool, preference config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) return func() tea.Msg { if !config.IsTUI { termgraphics.Probe(os.Stdin, os.Stdout) @@ -51,7 +52,7 @@ func (pm *PlayerManager) playTerminal(url, title, ratingKey string, noReport boo } playback := &terminalPlayback{ mpv: mpv, output: output, preference: preference, subtitles: subtitles, report: reportProgress, - status: PlayerStatus{ + status: PlayerStatus{Server: server, Title: title, File: url, Key: ratingKey, NoReport: noReport, Time: float64(startOffset) / 1000, }, diff --git a/internal/tui/tab_manager.go b/internal/tui/tab_manager.go index aba453f..8294cd5 100644 --- a/internal/tui/tab_manager.go +++ b/internal/tui/tab_manager.go @@ -15,6 +15,7 @@ type TabManager struct { navbar *navbar.Navbar activeTabIdx int libCount int + friendsIdx int sessionsIdx int historyIdx int tasksIdx int @@ -65,6 +66,14 @@ func (tm *TabManager) setupTabs(data plex.LoaderResult, theme tint.Tint, sidebar tm.libCount = len(newModels) + // Shared libraries never replace the user's own server or library tabs. + tm.friendsIdx = len(newModels) + friends := view.NewFriendsView(theme) + newModels = append(newModels, friends) + if tm.activeTabIdx == tm.friendsIdx { + cmds = append(cmds, friends.Init()) + } + // Status Section tm.sessionsIdx = len(newModels) tm.historyIdx = len(newModels) + 1 @@ -105,6 +114,7 @@ func (tm *TabManager) setupTabs(data plex.LoaderResult, theme tint.Tint, sidebar sections := []navbar.NavSection{ {Title: "Libraries", Items: libItems}, + {Title: "Friends Libraries", Items: []navbar.NavItem{{ID: "friends", Title: "Browse", Type: "friends"}}}, {Title: "Status", Items: statusItems}, {Title: "Search", Items: searchItems}, } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6e7d803..2974625 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -132,7 +132,11 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return c, tea.Batch(cmds...) - case poster.PosterLoadedMsg, detail.PosterDataMsg: + case view.FriendsServersMsg, view.FriendsLibrariesMsg: + friends := c.tabManager.tabModels[c.tabManager.friendsIdx] + _, cmd := friends.Update(msg) + return c, cmd + case poster.PosterLoadedMsg, detail.PosterDataMsg, detail.DataMsg: for _, model := range c.tabManager.tabModels { if model != nil { _, cmd := model.Update(msg) @@ -141,15 +145,18 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return c, tea.Batch(cmds...) case ui.MediaPageMsg: - // A request belongs to its library even if the user changed tabs or - // opened a dialog while the server was responding. + // Request IDs and server IDs keep asynchronous replies with their view. for _, model := range c.tabManager.tabModels { - if library, ok := model.(*view.MediaView); ok && library.GetSectionID() == msg.SectionID { - _, cmd := library.Update(msg) - return c, cmd + switch model := model.(type) { + case *view.MediaView: + _, cmd := model.Update(msg) + cmds = append(cmds, cmd) + case *view.FriendsView: + _, cmd := model.Update(msg) + cmds = append(cmds, cmd) } } - return c, nil + return c, tea.Batch(cmds...) case tea.WindowSizeMsg: ui.GetLayout().SetPlayerHeight(c.playerHeight(msg.Height)) ui.GetLayout().Update(msg.Width, msg.Height, c.playerStatus.Active()) @@ -234,9 +241,9 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return c, nil case ui.RequestPlayMsg: - return c, player.FetchAndPlay(msg.RatingKey, msg.Output) + return c, player.FetchAndPlay(msg.RatingKey, msg.Output, msg.Server) case ui.ResumeChoiceMsg: - return c, c.navigator.Push(resume.NewResumeOverlayModel(msg.Metadata, msg.Output, c.theme)) + return c, c.navigator.Push(resume.NewResumeOverlayModel(msg.Metadata, msg.Output, c.theme, msg.Server)) case ui.SelectMediaMsg: return c, c.handleSelectMedia(msg) case ui.JumpToDetailMsg: @@ -279,7 +286,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if meta != nil && meta.RatingKey != nil { if config.IsMusic(meta.Type) { if msg.String() == "v" { - return c, c.navigator.Push(playback.NewMusic(*meta.RatingKey, meta.Title, c.theme)) + return c, c.navigator.Push(playback.NewMusic(*meta.RatingKey, meta.Title, c.theme, c.mediaServer())) } output = config.Get().MusicOutput() if msg.String() == "ctrl+p" { @@ -287,10 +294,11 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if msg.String() == "v" { - return c, c.navigator.Push(playback.New(*meta.RatingKey, meta.Title, c.theme)) + return c, c.navigator.Push(playback.New(*meta.RatingKey, meta.Title, c.theme, c.mediaServer())) } + server := c.mediaServer() return c, func() tea.Msg { - return ui.RequestPlayMsg{ + return ui.RequestPlayMsg{Server: server, RatingKey: *meta.RatingKey, Output: output, } @@ -315,7 +323,11 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.musicControl(msg.String()) } case "/": - return c, c.navigator.Push(tuisearch.NewSearchOverlayModel(c.theme)) + server := c.mediaServer() + if server.ID == "" { + return c, c.alert.NewAlertCmd("error", "Choose a friend's server to search its shared libraries") + } + return c, c.navigator.Push(tuisearch.NewSearchOverlayModel(c.theme, server)) case "u": return c, c.triggerUserSwitch() case "shift+tab": @@ -350,10 +362,11 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { + server := config.MediaServer(msg.Server) returnTabIdx := c.tabManager.activeTabIdx - if msg.SectionID != "" { + if msg.SectionID != "" || server.Shared { return func() tea.Msg { - return ui.JumpToDetailMsg{ + return ui.JumpToDetailMsg{Server: server, SectionID: msg.SectionID, RatingKey: msg.RatingKey, Type: msg.Type, @@ -363,7 +376,7 @@ func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { } return func() tea.Msg { - meta, err := plex.GetMetadata(context.Background(), msg.RatingKey, false) + meta, err := plex.GetMetadata(server.Context(context.Background()), msg.RatingKey, false) if err != nil { return err } @@ -376,7 +389,7 @@ func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { } } if sectionID != "" { - return ui.JumpToDetailMsg{ + return ui.JumpToDetailMsg{Server: server, SectionID: sectionID, RatingKey: msg.RatingKey, Type: msg.Type, @@ -388,6 +401,13 @@ func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { } func (c *Controller) handleJumpToDetail(msg ui.JumpToDetailMsg) tea.Cmd { + if msg.Server.Shared { + friends := c.tabManager.tabModels[c.tabManager.friendsIdx].(*view.FriendsView) + c.returnTabIdx = -1 + c.focusPane(paneContent) + c.tabManager.SetActive(c.tabManager.friendsIdx) + return friends.ShowDetail(msg) + } slog.Debug("Controller: handleJumpToDetail", "key", msg.RatingKey, "type", msg.Type, "section", msg.SectionID) // 1. If SectionID is specified, find that specific tab @@ -479,6 +499,7 @@ func (c *Controller) handleSwitchUser(msg ui.SwitchUserMsg) tea.Cmd { // 3. Save both tokens cfg.HomeUser.AuthToken = res.UserPlexAccount.AuthToken cfg.HomeUser.AccessToken = accessToken + cfg.HomeUser.ServerID = serverID _ = cfg.Save() slog.Info("TUI: User switched successfully") @@ -734,3 +755,10 @@ func (c *Controller) renderPlayer() string { return playerStyle.Render(lipgloss.JoinVertical(lipgloss.Left, title, row2)) } + +func (c *Controller) mediaServer() config.ServerTarget { + if provider, ok := c.tabManager.ActiveModel().(ui.ServerProvider); ok { + return provider.MediaServer() + } + return config.Get().ServerTarget() +} diff --git a/internal/tui/view/detail/base.go b/internal/tui/view/detail/base.go index 80918e4..897e5fc 100644 --- a/internal/tui/view/detail/base.go +++ b/internal/tui/view/detail/base.go @@ -1,15 +1,18 @@ package detail import ( + "fmt" "github.com/LukeHagar/plexgo/models/components" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/ui" ) type DetailBase struct { + Server config.ServerTarget RatingKey string Metadata *components.Metadata Poster string @@ -23,8 +26,8 @@ type DetailBase struct { SynopsisVP viewport.Model } -func NewDetailBase(ratingKey string, theme tint.Tint) DetailBase { - return DetailBase{ +func NewDetailBase(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) DetailBase { + return DetailBase{Server: config.MediaServer(servers...), RatingKey: ratingKey, Theme: theme, Loading: true, @@ -43,6 +46,9 @@ func (b *DetailBase) IsAtRoot() bool { } func (b *DetailBase) Update(msg tea.Msg) tea.Cmd { + if !b.Accepts(msg) { + return nil + } var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -50,15 +56,16 @@ func (b *DetailBase) Update(msg tea.Msg) tea.Cmd { b.Height = msg.Height b.UpdateLayout() if b.Metadata != nil { - cmds = append(cmds, fetchPoster(b.Metadata, b.Width)) + cmds = append(cmds, fetchPoster(b.Metadata, b.Width, b.Server)) } case interface{ GetTheme() tint.Tint }: b.Theme = msg.GetTheme() case detailDataMsg: b.Loading = false + b.Err = msg.err b.Metadata = msg.metadata b.UpdateLayout() - cmds = append(cmds, fetchPoster(b.Metadata, b.Width)) + cmds = append(cmds, fetchPoster(b.Metadata, b.Width, b.Server)) case PosterDataMsg: if msg.ratingKey != b.RatingKey || msg.width != detailPosterWidth(b.Metadata) { return nil @@ -106,6 +113,9 @@ func (b *DetailBase) RenderHeader(rightWidth int) string { if b.Metadata.GrandparentTitle != nil && b.Metadata.Type == "episode" { title = *b.Metadata.GrandparentTitle + " - " + title } + if b.Server.Shared { + title = "Shared · " + b.Server.Name + " · " + title + } return titleStyle.Render(title) } @@ -129,3 +139,21 @@ func (b *DetailBase) showPoster() bool { layout := ui.GetLayout() return b.Poster != "" && layout.InnerWidth() >= 70 && layout.ContentHeight() >= 30 } + +func (b *DetailBase) Accepts(msg tea.Msg) bool { + switch msg := msg.(type) { + case DataMsg: + return (msg.serverID == "" || msg.serverID == b.Server.CacheNamespace()) && (msg.ratingKey == "" || msg.ratingKey == b.RatingKey) + case PosterDataMsg: + return msg.serverID == "" || msg.serverID == b.Server.CacheNamespace() + } + return true +} + +func (b *DetailBase) dataError(err error) DataMsg { + return DataMsg{serverID: b.Server.CacheNamespace(), ratingKey: b.RatingKey, err: err} +} + +func (b *DetailBase) errorView() string { + return fmt.Sprintf("Unable to load details: %v\n[r] Retry · [esc] Back", b.Err) +} diff --git a/internal/tui/view/detail/base_test.go b/internal/tui/view/detail/base_test.go new file mode 100644 index 0000000..1b8b568 --- /dev/null +++ b/internal/tui/view/detail/base_test.go @@ -0,0 +1,34 @@ +package detail + +import ( + "errors" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestDetailResponsesStayWithServerAndItem(t *testing.T) { + server := config.ServerTarget{ID: "friend", Token: "shared", Shared: true} + v := NewMovieDetailView("1", ui.PlexctlTheme, server) + v.Update(DataMsg{serverID: "different-server", ratingKey: "1", metadata: &components.Metadata{Title: "Wrong movie"}}) + v.Update(DataMsg{serverID: server.CacheNamespace(), ratingKey: "2", metadata: &components.Metadata{Title: "Another movie"}}) + if v.Metadata != nil || !v.Loading { + t.Fatal("a delayed response populated the wrong detail view") + } + v.Update(v.dataError(errors.New("server offline"))) + if !strings.Contains(v.View(), "server offline") || !strings.Contains(v.View(), "Retry") { + t.Fatal("shared detail failure did not offer retry") + } + manager := DetailManager{View: v} + cmd, handled := manager.Update(ui.KeyPress("r")) + if !handled || cmd == nil || !v.Loading { + t.Fatal("retry did not start a new detail request") + } + v.Update(DataMsg{serverID: server.CacheNamespace(), ratingKey: "1", metadata: &components.Metadata{Title: "Shared movie"}}) + if v.Err != nil || v.Metadata == nil || v.Metadata.Title != "Shared movie" { + t.Fatal("successful retry did not clear the error") + } +} diff --git a/internal/tui/view/detail/detail_shared.go b/internal/tui/view/detail/detail_shared.go index cd01101..a3421b5 100644 --- a/internal/tui/view/detail/detail_shared.go +++ b/internal/tui/view/detail/detail_shared.go @@ -18,18 +18,25 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) // Shared Message Types -type detailDataMsg struct { - metadata *components.Metadata - children []components.Metadata +type detailDataMsg = DataMsg + +type DataMsg struct { + err error + serverID string + ratingKey string + metadata *components.Metadata + children []components.Metadata } type PosterDataMsg struct { + serverID string ratingKey string width int view string @@ -64,7 +71,8 @@ func detailPosterWidth(metadata *components.Metadata) int { return ui.DetailPosterWidth } -func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { +func fetchPoster(metadata *components.Metadata, width int, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) targetWidth := detailPosterWidth(metadata) return func() tea.Msg { if metadata == nil { @@ -87,13 +95,13 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { // Check long-term cache for rendered string if rk != "" { - if cached, ok := artwork.Cached(rk, targetWidth); ok { - return PosterDataMsg{ratingKey: rk, width: targetWidth, view: cached} + if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, targetWidth); ok { + return PosterDataMsg{serverID: server.CacheNamespace(), ratingKey: rk, width: targetWidth, view: cached} } } start := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 10*time.Second) defer cancel() data, err := plex.GetImage(ctx, path) if err != nil { @@ -109,14 +117,14 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { return nil } - imgStr, err := artwork.Render(rk, img, targetWidth, 0) + imgStr, err := artwork.Render(server.CacheNamespace()+"/"+rk, img, targetWidth, 0) if err != nil { slog.Error("fetchPoster render failed", "error", err) return nil } slog.Debug("fetchPoster render complete", "duration", time.Since(start)) - return PosterDataMsg{ratingKey: rk, width: targetWidth, view: imgStr} + return PosterDataMsg{serverID: server.CacheNamespace(), ratingKey: rk, width: targetWidth, view: imgStr} } } diff --git a/internal/tui/view/detail/episode_detail.go b/internal/tui/view/detail/episode_detail.go index 4c6d0f6..d735372 100644 --- a/internal/tui/view/detail/episode_detail.go +++ b/internal/tui/view/detail/episode_detail.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/ui" ) @@ -17,9 +18,9 @@ type EpisodeDetailView struct { DetailBase } -func NewEpisodeDetailView(ratingKey string, theme tint.Tint) *EpisodeDetailView { +func NewEpisodeDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *EpisodeDetailView { return &EpisodeDetailView{ - DetailBase: NewDetailBase(ratingKey, theme), + DetailBase: NewDetailBase(ratingKey, theme, servers...), } } @@ -30,12 +31,12 @@ func (v *EpisodeDetailView) Init() tea.Cmd { func (v *EpisodeDetailView) Refresh() tea.Cmd { v.Loading = true return func() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } - return detailDataMsg{metadata: meta} + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta} } } @@ -44,15 +45,19 @@ func (v *EpisodeDetailView) GetSelectedMetadata() *components.Metadata { } func (v *EpisodeDetailView) fetchData() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, false) if err != nil { - return err + return v.dataError(err) } - return detailDataMsg{metadata: meta} + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta} } func (v *EpisodeDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !v.Accepts(msg) { + return v, nil + } + var cmds []tea.Cmd switch msg := msg.(type) { @@ -63,7 +68,7 @@ func (v *EpisodeDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "s": if v.Metadata != nil && v.Metadata.ParentRatingKey != nil { return v, func() tea.Msg { - return ui.JumpToDetailMsg{ + return ui.JumpToDetailMsg{Server: v.Server, RatingKey: *v.Metadata.ParentRatingKey, Type: "season", } @@ -72,7 +77,7 @@ func (v *EpisodeDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "S": if v.Metadata != nil && v.Metadata.GrandparentRatingKey != nil { return v, func() tea.Msg { - return ui.JumpToDetailMsg{ + return ui.JumpToDetailMsg{Server: v.Server, RatingKey: *v.Metadata.GrandparentRatingKey, Type: "show", } @@ -101,6 +106,9 @@ func (v *EpisodeDetailView) View() string { if v.Loading { return lipgloss.NewStyle().Padding(2).Render("Loading episode details...") } + if v.Err != nil { + return v.errorView() + } if v.Metadata == nil { return "No metadata available" } diff --git a/internal/tui/view/detail/manager.go b/internal/tui/view/detail/manager.go index 605b12b..b36bd49 100644 --- a/internal/tui/view/detail/manager.go +++ b/internal/tui/view/detail/manager.go @@ -25,6 +25,11 @@ func (m *DetailManager) Update(msg tea.Msg) (tea.Cmd, bool) { if m.View == nil { return nil, false } + if key, ok := msg.(tea.KeyMsg); ok && key.String() == "r" { + if refresher, ok := m.View.(ui.Refreshable); ok { + return refresher.Refresh(), true + } + } if _, ok := msg.(BackMsg); ok { if rc, ok := m.View.(ui.RootChecker); !ok || rc.IsAtRoot() { diff --git a/internal/tui/view/detail/movie_detail.go b/internal/tui/view/detail/movie_detail.go index 84d90a0..b85fa95 100644 --- a/internal/tui/view/detail/movie_detail.go +++ b/internal/tui/view/detail/movie_detail.go @@ -19,9 +19,9 @@ type MovieDetailView struct { DetailBase } -func NewMovieDetailView(ratingKey string, theme tint.Tint) *MovieDetailView { +func NewMovieDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *MovieDetailView { return &MovieDetailView{ - DetailBase: NewDetailBase(ratingKey, theme), + DetailBase: NewDetailBase(ratingKey, theme, servers...), } } @@ -32,12 +32,12 @@ func (v *MovieDetailView) Init() tea.Cmd { func (v *MovieDetailView) Refresh() tea.Cmd { v.Loading = true return func() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } - return detailDataMsg{metadata: meta} + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta} } } @@ -46,15 +46,19 @@ func (v *MovieDetailView) GetSelectedMetadata() *components.Metadata { } func (v *MovieDetailView) fetchData() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, false) if err != nil { - return err + return v.dataError(err) } - return detailDataMsg{metadata: meta} + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta} } func (v *MovieDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !v.Accepts(msg) { + return v, nil + } + var cmds []tea.Cmd switch msg := msg.(type) { @@ -76,7 +80,7 @@ func (v *MovieDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (v *MovieDetailView) playTrailer() tea.Cmd { - return player.FetchAndPlayTrailer(v.Metadata, config.VideoWindow) + return player.FetchAndPlayTrailer(v.Metadata, config.VideoWindow, v.Server) } func (v *MovieDetailView) hasTrailer() bool { @@ -112,6 +116,9 @@ func (v *MovieDetailView) View() string { if v.Loading { return lipgloss.NewStyle().Padding(2).Render("Loading movie details...") } + if v.Err != nil { + return v.errorView() + } if v.Metadata == nil { return "No metadata available" } diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go index 8e3cb26..6f8c641 100644 --- a/internal/tui/view/detail/music_detail.go +++ b/internal/tui/view/detail/music_detail.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/widget/poster" "github.com/ygelfand/plexctl/internal/ui" @@ -31,11 +32,11 @@ type MusicDetailView struct { jumpNote string } -func NewMusicDetailView(ratingKey string, theme tint.Tint) *MusicDetailView { +func NewMusicDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *MusicDetailView { items := createBaseDetailList() items.Paginator.Type = paginator.Arabic - return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme), items: items, - albums: poster.NewPosterGrid(nil, theme)} + return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme, servers...), items: items, + albums: poster.NewPosterGrid(nil, theme, servers...)} } func (v *MusicDetailView) Init() tea.Cmd { return v.fetchData } @@ -49,20 +50,20 @@ func (v *MusicDetailView) Refresh() tea.Cmd { } func (v *MusicDetailView) fetchData() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(v.Server.Context(context.Background()), 30*time.Second) defer cancel() meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } var children []components.Metadata if meta.Type == "artist" || meta.Type == "album" { children, err = plex.GetChildren(ctx, v.RatingKey) if err != nil { - return err + return v.dataError(err) } } - return detailDataMsg{metadata: meta, children: children} + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} } func (v *MusicDetailView) GetSelectedMetadata() *components.Metadata { @@ -133,7 +134,7 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if v.selected != nil { if _, back := msg.(BackMsg); back && v.selected.IsAtRoot() { v.selected = nil - return v, tea.Batch(v.albums.Init(), fetchPoster(v.Metadata, v.Width)) + return v, tea.Batch(v.albums.Init(), fetchPoster(v.Metadata, v.Width, v.Server)) } _, cmd := v.selected.Update(msg) return v, cmd @@ -158,6 +159,9 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { case detailDataMsg: + if !v.Accepts(msg) { + return v, nil + } if msg.metadata != nil && msg.metadata.RatingKey != nil && *msg.metadata.RatingKey != v.RatingKey { return v, nil } @@ -184,7 +188,7 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, cmd case poster.ItemSelectedMsg: if msg.Metadata.RatingKey != nil { - v.selected = NewMusicDetailView(*msg.Metadata.RatingKey, v.Theme) + v.selected = NewMusicDetailView(*msg.Metadata.RatingKey, v.Theme, v.Server) return v, v.selected.Init() } case tea.WindowSizeMsg: @@ -197,7 +201,7 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": if meta := v.GetSelectedMetadata(); meta != nil && meta.RatingKey != nil && meta.Type != "artist" { - v.selected = NewMusicDetailView(*meta.RatingKey, v.Theme) + v.selected = NewMusicDetailView(*meta.RatingKey, v.Theme, v.Server) return v, v.selected.Init() } case "esc", "backspace": diff --git a/internal/tui/view/detail/season_detail.go b/internal/tui/view/detail/season_detail.go index 33733a7..e04f89a 100644 --- a/internal/tui/view/detail/season_detail.go +++ b/internal/tui/view/detail/season_detail.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/ui" ) @@ -20,9 +21,9 @@ type SeasonDetailView struct { listRect ui.Rect } -func NewSeasonDetailView(ratingKey string, theme tint.Tint) *SeasonDetailView { +func NewSeasonDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *SeasonDetailView { return &SeasonDetailView{ - DetailBase: NewDetailBase(ratingKey, theme), + DetailBase: NewDetailBase(ratingKey, theme, servers...), episodeList: createBaseDetailList(), } } @@ -37,13 +38,16 @@ func (v *SeasonDetailView) Refresh() tea.Cmd { } v.Loading = true return func() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } - children, _ := plex.GetChildren(ctx, v.RatingKey) - return detailDataMsg{metadata: meta, children: children} + children, err := plex.GetChildren(ctx, v.RatingKey) + if err != nil { + return v.dataError(err) + } + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} } } @@ -59,14 +63,17 @@ func (v *SeasonDetailView) GetSelectedMetadata() *components.Metadata { } func (v *SeasonDetailView) fetchData() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } - children, _ := plex.GetChildren(ctx, v.RatingKey) - return detailDataMsg{metadata: meta, children: children} + children, err := plex.GetChildren(ctx, v.RatingKey) + if err != nil { + return v.dataError(err) + } + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} } func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -80,6 +87,10 @@ func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, cmd } + if !v.Accepts(msg) { + return v, nil + } + var cmds []tea.Cmd if mouse, ok := msg.(tea.MouseMsg); ok { if local, inside := v.listRect.Local(mouse); inside { @@ -103,7 +114,7 @@ func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if v.episodeList.SelectedItem() != nil { child := v.episodeList.SelectedItem().(metadataItem).metadata - v.selectedEpisode = NewEpisodeDetailView(*child.RatingKey, v.Theme) + v.selectedEpisode = NewEpisodeDetailView(*child.RatingKey, v.Theme, v.Server) return v, v.selectedEpisode.Init() } case "esc", "backspace": @@ -111,7 +122,7 @@ func (v *SeasonDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "S": if v.Metadata != nil && v.Metadata.ParentRatingKey != nil { return v, func() tea.Msg { - return ui.JumpToDetailMsg{ + return ui.JumpToDetailMsg{Server: v.Server, RatingKey: *v.Metadata.ParentRatingKey, Type: "show", } @@ -155,6 +166,9 @@ func (v *SeasonDetailView) View() string { if v.Loading { return lipgloss.NewStyle().Padding(2).Render("Loading season details...") } + if v.Err != nil { + return v.errorView() + } if v.Metadata == nil { return "No metadata available" } diff --git a/internal/tui/view/detail/show_detail.go b/internal/tui/view/detail/show_detail.go index 83e4f7d..c3d0cde 100644 --- a/internal/tui/view/detail/show_detail.go +++ b/internal/tui/view/detail/show_detail.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/ui" ) @@ -22,9 +23,9 @@ type ShowDetailView struct { listRect ui.Rect } -func NewShowDetailView(ratingKey string, theme tint.Tint) *ShowDetailView { +func NewShowDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *ShowDetailView { return &ShowDetailView{ - DetailBase: NewDetailBase(ratingKey, theme), + DetailBase: NewDetailBase(ratingKey, theme, servers...), seasonList: createBaseDetailList(), } } @@ -39,13 +40,16 @@ func (v *ShowDetailView) Refresh() tea.Cmd { } v.Loading = true return func() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, true) if err != nil { - return err + return v.dataError(err) } - children, _ := plex.GetChildren(ctx, v.RatingKey) - return detailDataMsg{metadata: meta, children: children} + children, err := plex.GetChildren(ctx, v.RatingKey) + if err != nil { + return v.dataError(err) + } + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} } } @@ -61,14 +65,17 @@ func (v *ShowDetailView) GetSelectedMetadata() *components.Metadata { } func (v *ShowDetailView) fetchData() tea.Msg { - ctx := context.Background() + ctx := v.Server.Context(context.Background()) meta, err := plex.GetMetadata(ctx, v.RatingKey, false) if err != nil { - return err + return v.dataError(err) } - children, _ := plex.GetChildren(ctx, v.RatingKey) - return detailDataMsg{metadata: meta, children: children} + children, err := plex.GetChildren(ctx, v.RatingKey) + if err != nil { + return v.dataError(err) + } + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} } func (v *ShowDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -82,6 +89,10 @@ func (v *ShowDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, cmd } + if !v.Accepts(msg) { + return v, nil + } + var cmds []tea.Cmd if mouse, ok := msg.(tea.MouseMsg); ok { if local, inside := v.listRect.Local(mouse); inside { @@ -105,7 +116,7 @@ func (v *ShowDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if v.seasonList.SelectedItem() != nil { child := v.seasonList.SelectedItem().(metadataItem).metadata - v.selectedSeason = NewSeasonDetailView(*child.RatingKey, v.Theme) + v.selectedSeason = NewSeasonDetailView(*child.RatingKey, v.Theme, v.Server) return v, v.selectedSeason.Init() } case "esc", "backspace": @@ -147,6 +158,9 @@ func (v *ShowDetailView) View() string { if v.Loading { return lipgloss.NewStyle().Padding(2).Render("Loading show details...") } + if v.Err != nil { + return v.errorView() + } if v.Metadata == nil { return "No metadata available" } diff --git a/internal/tui/view/friends.go b/internal/tui/view/friends.go new file mode 100644 index 0000000..639521e --- /dev/null +++ b/internal/tui/view/friends.go @@ -0,0 +1,275 @@ +package view + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/ui" +) + +// FriendsView keeps shared browsing separate from the user's own library tabs. +type FriendsView struct { + theme tint.Tint + table ui.Table + servers []components.PlexDevice + libraries []plex.LibraryInfo + server config.ServerTarget + media *MediaView + requestID uint64 + loading, loaded bool + err error +} + +var friendsRequestID atomic.Uint64 + +type FriendsServersMsg struct { + RequestID uint64 + Servers []components.PlexDevice + Err error +} + +type FriendsLibrariesMsg struct { + RequestID uint64 + Server config.ServerTarget + Libraries []plex.LibraryInfo + Err error +} + +func NewFriendsView(theme tint.Tint) *FriendsView { + return &FriendsView{theme: theme, table: ui.NewTable([]table.Column{ + {Title: "SERVER / LIBRARY", Width: 35}, {Title: "SHARED BY / TYPE", Width: 30}, + }, theme)} +} + +func (v *FriendsView) Init() tea.Cmd { + if v.loaded || v.loading { + return nil + } + return v.Refresh() +} + +func (v *FriendsView) Refresh() tea.Cmd { + if v.media != nil { + return v.media.Refresh() + } + v.requestID = friendsRequestID.Add(1) + v.loading, v.err = true, nil + id := v.requestID + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + servers, err := plex.DiscoverServers(ctx) + var shared []components.PlexDevice + for _, server := range servers { + if !server.Owned { + shared = append(shared, server) + } + } + return FriendsServersMsg{RequestID: id, Servers: shared, Err: err} + } +} + +func (v *FriendsView) connect(device components.PlexDevice) tea.Cmd { + v.requestID = friendsRequestID.Add(1) + v.loading, v.err = true, nil + id := v.requestID + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + server, err := plex.ConnectServer(ctx, device) + result := FriendsLibrariesMsg{RequestID: id, Server: server, Err: err} + if err != nil { + return result + } + ctx = server.Context(ctx) + client, err := plex.NewClientForContext(ctx) + if err != nil { + result.Err = err + return result + } + response, err := client.SDK.Library.GetSections(ctx) + if err != nil { + result.Err = fmt.Errorf("load shared libraries: %w", err) + return result + } + if response.Object == nil || response.Object.MediaContainer == nil { + result.Err = fmt.Errorf("no library data returned by %s", server.Name) + return result + } + for _, dir := range response.Object.MediaContainer.Directory { + if dir.Key != nil && dir.Title != nil { + result.Libraries = append(result.Libraries, plex.LibraryInfo{ID: *dir.Key, Title: *dir.Title, Type: string(dir.Type)}) + } + } + return result + } +} + +func (v *FriendsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case FriendsServersMsg: + if msg.RequestID != v.requestID { + return v, nil + } + v.loading, v.loaded, v.err = false, msg.Err == nil, msg.Err + if msg.Err == nil { + v.servers, v.server, v.libraries = msg.Servers, config.ServerTarget{}, nil + v.serverRows() + } + return v, nil + case FriendsLibrariesMsg: + if msg.RequestID != v.requestID { + return v, nil + } + v.loading, v.err = false, msg.Err + if msg.Err == nil { + v.server, v.libraries = msg.Server, msg.Libraries + var rows []table.Row + for _, lib := range v.libraries { + rows = append(rows, table.Row{lib.Title, lib.Type}) + } + v.table.SetRows(rows) + v.table.SetCursor(0) + } + return v, nil + case tea.WindowSizeMsg: + v.table.SetWidth(ui.GetLayout().InnerWidth()) + v.table.SetHeight(max(1, ui.GetLayout().ContentHeight()-5)) + case ui.ThemeChangedMsg: + v.theme = msg.Theme + ui.UpdateTableTheme(&v.table, ui.GetLayout().InnerWidth(), ui.GetLayout().TotalHeight()) + } + if v.media != nil { + if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "esc" || key.String() == "backspace") && !v.media.details.Active() { + v.media = nil + return v, nil + } + _, cmd := v.media.Update(msg) + return v, cmd + } + if mouse, ok := msg.(tea.MouseMsg); ok { + mouse.Y -= 2 + if v.table.Click(mouse) { + msg = ui.KeyPress("enter") + } + } + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc", "backspace": + v.requestID = friendsRequestID.Add(1) // Ignore a connection finishing after Back. + v.loading, v.err, v.server, v.libraries = false, nil, config.ServerTarget{}, nil + v.serverRows() + return v, nil + case "r": + return v, v.Refresh() + case "enter": + if v.loading { + return v, nil + } + i := v.table.Cursor() + if v.server.ID == "" { + if i >= 0 && i < len(v.servers) { + return v, v.connect(v.servers[i]) + } + } else if i >= 0 && i < len(v.libraries) { + lib := v.libraries[i] + v.media = NewMediaView(lib.ID, v.server.Name+" / "+lib.Title, v.theme, v.server) + return v, v.media.Init() + } + return v, nil + } + } + var cmd tea.Cmd + v.table, cmd = v.table.Update(msg) + return v, cmd +} + +func (v *FriendsView) serverRows() { + var rows []table.Row + for _, server := range v.servers { + rows = append(rows, table.Row{server.Name, plex.ServerDescription(server)}) + } + v.table.SetRows(rows) + v.table.SetCursor(0) +} + +func (v *FriendsView) View() string { + if v.media != nil { + return v.media.View() + } + heading := "Friends Libraries" + if v.server.ID != "" { + heading = v.server.Name + " · Shared libraries" + } + heading = lipgloss.NewStyle().Foreground(ui.Accent(v.theme)).Bold(true).Render(heading) + if v.loading { + return heading + "\n\nConnecting to Plex… [esc] Back" + } + if v.err != nil { + return heading + "\n\n" + v.err.Error() + "\n\n[r] Refresh · [enter] Retry selection · [esc] Back" + } + if len(v.servers) == 0 { + return heading + "\n\nNo shared servers are available to this Plex user.\nAccept a library invitation in Plex, then press r to refresh." + } + if v.server.ID != "" && len(v.libraries) == 0 { + return heading + "\n\nThis server has no libraries shared with this Plex user.\n[esc] Friends · [r] Refresh" + } + hint := "[enter] Browse shared libraries · [r] Refresh" + if v.server.ID != "" { + hint = "[enter] Browse library · [/] Search this server · [esc] Friends" + } + return heading + "\n\n" + v.table.View() + "\n" + hint +} + +func (v *FriendsView) MediaServer() config.ServerTarget { return v.server } +func (v *FriendsView) ActionRows() int { return 1 } +func (v *FriendsView) GetSelectedMetadata() *components.Metadata { + if v.media != nil { + return v.media.GetSelectedMetadata() + } + return nil +} +func (v *FriendsView) ShowDetail(msg ui.JumpToDetailMsg) tea.Cmd { + if msg.Server.CacheNamespace() != v.server.CacheNamespace() { + return nil + } + if v.media == nil || (msg.SectionID != "" && v.media.sectionID != msg.SectionID) { + title := "Search results" + for _, lib := range v.libraries { + if lib.ID == msg.SectionID { + title = lib.Title + break + } + } + v.media = NewMediaView(msg.SectionID, v.server.Name+" / "+title, v.theme, v.server) + } + return v.media.ShowDetail(msg.RatingKey, msg.Type) +} +func (v *FriendsView) CanJumpLetters() bool { return v.media != nil && v.media.CanJumpLetters() } +func (v *FriendsView) AlphabetRail() *ui.AlphabetRail { + if v.media != nil { + return v.media.AlphabetRail() + } + return nil +} +func (v *FriendsView) JumpToLetter(letter string) tea.Cmd { + if v.media != nil { + return v.media.JumpToLetter(letter) + } + return nil +} +func (v *FriendsView) HelpKeys() []ui.HelpKey { + if v.media != nil { + return v.media.HelpKeys() + } + return []ui.HelpKey{{Key: "enter", Desc: "Browse shared server or library"}, {Key: "/", Desc: "Search the selected friend's server"}, {Key: "esc", Desc: "Back to friends"}, {Key: "r", Desc: "Refresh shared servers"}} +} diff --git a/internal/tui/view/friends_test.go b/internal/tui/view/friends_test.go new file mode 100644 index 0000000..ab96df8 --- /dev/null +++ b/internal/tui/view/friends_test.go @@ -0,0 +1,107 @@ +package view + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/search" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestFriendsBrowseSearchAndBackKeepOwnedServer(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.Token, cfg.HomeUser = "owned-token", config.HomeUser{} + cfg.DefaultServer, cfg.CacheDir, cfg.NoCache = "owned", t.TempDir(), true + cfg.Servers = map[string]config.Server{"owned": {URL: "https://owned.invalid"}} + cfg.DefaultViewMode = config.ViewModeList + ui.GetLayout().Update(120, 40, false) + friend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "friend-token" { + t.Error("used owned credentials for a friend") + http.Error(w, "denied", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + meta := map[string]any{"ratingKey": "1", "title": "Shared Film", "type": "movie", "librarySectionID": "1"} + var container any + switch r.URL.Path { + case "/identity": + container = map[string]any{"machineIdentifier": "friend"} + case "/library/sections", "/library/sections/all": + container = map[string]any{"Directory": []any{map[string]any{"key": "1", "title": "Shared Movies", "type": "movie"}}} + case "/library/sections/1/all", "/library/metadata/1": + container = map[string]any{"Metadata": []any{meta}, "totalSize": 1} + case "/hubs/search": + container = map[string]any{"Hub": []any{map[string]any{"type": "movie", "Metadata": []any{meta}}}} + default: + t.Errorf("unexpected request %s", r.URL.Path) + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": container}) + })) + defer friend.Close() + v := NewFriendsView(ui.PlexctlTheme) + v.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + v.Update(FriendsServersMsg{Servers: []components.PlexDevice{{Name: "Friend", ClientIdentifier: "friend", AccessToken: "friend-token", Connections: []components.Connections{{URI: friend.URL}}}}}) + _, connect := v.Update(ui.KeyPress("enter")) + v.Update(connect()) + if v.server.ID != "friend" || !strings.Contains(v.View(), "Shared Movies") { + t.Fatalf("shared libraries were not listed: %s", v.View()) + } + entries, err := search.Live(v.server.Context(context.Background()), "Shared") + if err != nil || len(entries) != 1 || entries[0].Title != "Shared Film" { + t.Fatalf("shared search failed: %v", err) + } + _, browse := v.Update(ui.KeyPress("enter")) + v.Update(browse()) + if selected := v.GetSelectedMetadata(); selected == nil || selected.Title != "Shared Film" { + t.Fatal("shared media did not load") + } + _, details := v.Update(ui.KeyPress("enter")) + v.Update(details()) + if selected := v.GetSelectedMetadata(); selected == nil || selected.Title != "Shared Film" { + t.Fatal("shared detail did not load") + } + _, back := v.Update(ui.KeyPress("esc")) + v.Update(back()) + v.Update(ui.KeyPress("esc")) + if v.media != nil || v.server.ID != "friend" { + t.Fatal("back did not restore shared libraries") + } + v.Update(ui.KeyPress("esc")) + if v.server.ID != "" || !strings.Contains(v.View(), "Friend") { + t.Fatal("back did not restore friends") + } + if cfg.DefaultServer != "owned" || cfg.ServerToken() != "owned-token" { + t.Fatal("browsing a friend changed the configured server") + } +} + +func TestFriendsEmptyOfflineAndStaleConnection(t *testing.T) { + v := NewFriendsView(ui.PlexctlTheme) + v.Update(FriendsServersMsg{}) + if !strings.Contains(v.View(), "No shared servers") { + t.Fatal("missing empty-state explanation") + } + v.requestID = 42 + v.Update(ui.KeyPress("esc")) + v.Update(FriendsLibrariesMsg{RequestID: 42, Server: config.ServerTarget{ID: "stale"}}) + if v.server.ID != "" { + t.Fatal("cancelled connection replaced the view") + } + v.Update(FriendsServersMsg{RequestID: v.requestID, Err: context.DeadlineExceeded}) + if !strings.Contains(v.View(), "Refresh") || v.loading { + t.Fatal("failed discovery cannot be retried") + } +} diff --git a/internal/tui/view/media.go b/internal/tui/view/media.go index 78be12e..2c2220c 100644 --- a/internal/tui/view/media.go +++ b/internal/tui/view/media.go @@ -31,6 +31,7 @@ const ( ) type MediaView struct { + server config.ServerTarget sectionID string title string table ui.Table @@ -55,7 +56,8 @@ type MediaView struct { var pageRequestID atomic.Uint64 -func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { +func NewMediaView(sectionID, title string, theme tint.Tint, servers ...config.ServerTarget) *MediaView { + serverTarget := config.MediaServer(servers...) columns := []table.Column{ {Title: "W", Width: 3}, {Title: "TITLE", Width: 45}, @@ -76,7 +78,7 @@ func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { } _, server, ok := cfg.GetActiveServer() - if ok { + if ok && serverTarget.ID == cfg.DefaultServer { if opts, ok := server.Libraries.Settings[sectionID]; ok && opts.ViewMode != "" { mode = opts.ViewMode } @@ -88,10 +90,11 @@ func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { } return &MediaView{ + server: serverTarget, sectionID: sectionID, title: title, table: t, - posterGrid: poster.NewPosterGrid(nil, theme), + posterGrid: poster.NewPosterGrid(nil, theme, serverTarget), theme: theme, viewMode: vMode, allMetadata: []components.Metadata{}, @@ -132,18 +135,17 @@ func (v *MediaView) fetchPage(start int) tea.Cmd { sectionID, requestID := v.sectionID, pageRequestID.Add(1) v.requestID = requestID return func() tea.Msg { - page := ui.MediaPageMsg{SectionID: sectionID, RequestID: requestID, Start: start, Total: -1} - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + page := ui.MediaPageMsg{ServerID: v.server.ID, SectionID: sectionID, RequestID: requestID, Start: start, Total: -1} + ctx, cancel := context.WithTimeout(v.server.Context(context.Background()), 30*time.Second) defer cancel() slog.Debug("MediaView: fetchPage started", "section", sectionID, "start", start) - client, err := plex.NewClient() + client, err := plex.NewClientForContext(ctx) if err != nil { page.Err = err return page } cfg := config.Get() - serverID, _, _ := cfg.GetActiveServer() cm, _ := cache.Get(cfg.CacheDir) req := operations.ListContentRequest{ @@ -154,7 +156,7 @@ func (v *MediaView) fetchPage(start int) tea.Cmd { } var body components.MediaContainerWithMetadata - err = cache.AutoCache(cm, serverID, req, plex.LibraryCacheTTL, &body, func() (*components.MediaContainerWithMetadata, error) { + err = cache.AutoCache(cm, v.server.CacheNamespace(), req, plex.LibraryCacheTTL, &body, func() (*components.MediaContainerWithMetadata, error) { res, err := client.SDK.Content.ListContent(ctx, req) if err != nil { return nil, err @@ -182,6 +184,9 @@ func (v *MediaView) fetchPage(start int) tea.Cmd { } func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(detail.DataMsg); ok && !v.details.Active() { + return v, nil + } if posterMsg, ok := msg.(poster.PosterLoadedMsg); ok { v.posterGrid.Update(posterMsg) } @@ -229,7 +234,7 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.UpdateTableTheme(&v.table, v.contentRect.Width, ui.GetLayout().TotalHeight()) v.table.SetHeight(v.contentRect.Height) case ui.MediaPageMsg: - if msg.SectionID != v.sectionID || msg.RequestID != v.requestID || msg.Start != v.loadedItems { + if (msg.ServerID != "" && msg.ServerID != v.server.ID) || msg.SectionID != v.sectionID || msg.RequestID != v.requestID || msg.Start != v.loadedItems { return v, nil } v.isLoading = false @@ -281,7 +286,7 @@ func (v *MediaView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.table.SetCursor(v.posterGrid.Cursor) } - if ok { + if ok && id == v.server.ID { if server.Libraries.Settings == nil { server.Libraries.Settings = make(map[string]config.LibraryOptions) } @@ -453,15 +458,15 @@ func (v *MediaView) ShowDetail(ratingKey, mediaType string) tea.Cmd { var dv tea.Model switch mediaType { case "show": - dv = detail.NewShowDetailView(ratingKey, v.theme) + dv = detail.NewShowDetailView(ratingKey, v.theme, v.server) case "season": - dv = detail.NewSeasonDetailView(ratingKey, v.theme) + dv = detail.NewSeasonDetailView(ratingKey, v.theme, v.server) case "episode": - dv = detail.NewEpisodeDetailView(ratingKey, v.theme) + dv = detail.NewEpisodeDetailView(ratingKey, v.theme, v.server) case "artist", "album", "track": - dv = detail.NewMusicDetailView(ratingKey, v.theme) + dv = detail.NewMusicDetailView(ratingKey, v.theme, v.server) default: - dv = detail.NewMovieDetailView(ratingKey, v.theme) + dv = detail.NewMovieDetailView(ratingKey, v.theme, v.server) } layout := ui.GetLayout() @@ -524,6 +529,9 @@ func (v *MediaView) View() string { status = "Loading…" } footer := count + " · [g] A–Z" + if v.server.Shared { + footer = "Shared · " + v.title + " · " + footer + } if status != "" { footer += " · " + status } @@ -543,3 +551,5 @@ func (v *MediaView) syncTableRows() { } v.table.SetRows(rows) } + +func (v *MediaView) MediaServer() config.ServerTarget { return v.server } diff --git a/internal/tui/widget/playback/playback.go b/internal/tui/widget/playback/playback.go index 2df312c..c0df82b 100644 --- a/internal/tui/widget/playback/playback.go +++ b/internal/tui/widget/playback/playback.go @@ -25,6 +25,7 @@ var choices = []choice{ } type Model struct { + server config.ServerTarget ratingKey string title string theme tint.Tint @@ -34,8 +35,8 @@ type Model struct { music bool } -func New(ratingKey, title string, theme tint.Tint) *Model { - m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58, choices: choices} +func New(ratingKey, title string, theme tint.Tint, servers ...config.ServerTarget) *Model { + m := &Model{server: config.MediaServer(servers...), ratingKey: ratingKey, title: title, theme: theme, width: 58, choices: choices} for i, choice := range choices { if choice.output == config.Get().TerminalVideo { m.choice = i @@ -44,8 +45,8 @@ func New(ratingKey, title string, theme tint.Tint) *Model { return m } -func NewMusic(ratingKey, title string, theme tint.Tint) *Model { - m := &Model{ratingKey: ratingKey, title: title, theme: theme, width: 58, music: true, +func NewMusic(ratingKey, title string, theme tint.Tint, servers ...config.ServerTarget) *Model { + m := &Model{server: config.MediaServer(servers...), ratingKey: ratingKey, title: title, theme: theme, width: 58, music: true, choices: []choice{{"In this terminal · audio + visualizer", config.AudioTerminal}, {"Separate player window", config.VideoWindow}}} if config.Get().MusicOutput() == config.VideoWindow { @@ -84,7 +85,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": output := m.choices[m.choice].output return nil, func() tea.Msg { - return ui.RequestPlayMsg{RatingKey: m.ratingKey, Output: output} + return ui.RequestPlayMsg{Server: m.server, RatingKey: m.ratingKey, Output: output} } } } diff --git a/internal/tui/widget/poster/grid.go b/internal/tui/widget/poster/grid.go index d2b6dfb..9c4005a 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -8,18 +8,21 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) type PosterGrid struct { + server config.ServerTarget Items []*PosterItem Cursor int Theme tint.Tint viewport viewport.Model } -func NewPosterGrid(metadata []components.Metadata, theme tint.Tint) *PosterGrid { +func NewPosterGrid(metadata []components.Metadata, theme tint.Tint, servers ...config.ServerTarget) *PosterGrid { + server := config.MediaServer(servers...) items := make([]*PosterItem, len(metadata)) for i, m := range metadata { items[i] = &PosterItem{Metadata: m, Loading: true} @@ -29,7 +32,7 @@ func NewPosterGrid(metadata []components.Metadata, theme tint.Tint) *PosterGrid rk = *m.RatingKey } if rk != "" { - if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { items[i].Poster = cached items[i].Loading = false } @@ -37,6 +40,7 @@ func NewPosterGrid(metadata []components.Metadata, theme tint.Tint) *PosterGrid } return &PosterGrid{ + server: server, Items: items, Theme: theme, viewport: viewport.New(0, 0), @@ -89,7 +93,7 @@ func (m *PosterGrid) SetItems(metadata []components.Metadata) tea.Cmd { rk = *meta.RatingKey } if rk != "" { - if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(m.server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -120,7 +124,7 @@ func (m *PosterGrid) fetchVisible() tea.Cmd { for i := startIdx; i < endIdx; i++ { if m.Items[i].Loading && !m.Items[i].Fetching { m.Items[i].Fetching = true - cmds = append(cmds, FetchPoster(0, i, m.Items[i].Metadata)) + cmds = append(cmds, FetchPoster(0, i, m.Items[i].Metadata, m.server)) } } return tea.Batch(cmds...) @@ -139,6 +143,9 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.fetchVisible()) case PosterLoadedMsg: + if msg.ServerID != "" && msg.ServerID != m.server.CacheNamespace() { + return m, nil + } if msg.Index >= 0 && msg.Index < len(m.Items) { if msg.RatingKey != ui.PtrToString(m.Items[msg.Index].Metadata.RatingKey) { return m, nil diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index ed7cac4..a11f0a5 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -15,6 +15,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" @@ -28,6 +29,7 @@ type PosterItem struct { } type PosterLoadedMsg struct { + ServerID string ListID int Index int View string @@ -39,17 +41,18 @@ type ItemSelectedMsg struct { } // FetchPoster returns a command to fetch and render a poster -func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { +func FetchPoster(listID int, index int, metadata components.Metadata, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) return func() tea.Msg { rk := "" if metadata.RatingKey != nil { rk = *metadata.RatingKey } - result := PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk} + result := PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk} if rk != "" { - if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { - return PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk, View: cached} + if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { + return PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk, View: cached} } } @@ -72,7 +75,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { return result } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 10*time.Second) defer cancel() data, err := plex.GetImage(ctx, path) if err != nil { @@ -88,12 +91,12 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { if metadata.Type == "album" || metadata.Type == "track" { height = min(height, max(1, (ui.PosterWidth*img.Bounds().Dy()/img.Bounds().Dx()+1)/2)) } - imgStr, err := artwork.Render(rk, img, ui.PosterWidth, height) + imgStr, err := artwork.Render(server.CacheNamespace()+"/"+rk, img, ui.PosterWidth, height) if err != nil { return result } - return PosterLoadedMsg{ListID: listID, Index: index, RatingKey: rk, View: imgStr} + return PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk, View: imgStr} } } diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 2bb6f6b..5407915 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -5,11 +5,13 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) type PosterList struct { + server config.ServerTarget ID int Title string Items []*PosterItem @@ -19,7 +21,8 @@ type PosterList struct { Active bool } -func NewPosterList(id int, title string, metadata []components.Metadata, theme tint.Tint) *PosterList { +func NewPosterList(id int, title string, metadata []components.Metadata, theme tint.Tint, servers ...config.ServerTarget) *PosterList { + server := config.MediaServer(servers...) items := make([]*PosterItem, len(metadata)) for i, m := range metadata { items[i] = &PosterItem{Metadata: m, Loading: true} @@ -29,7 +32,7 @@ func NewPosterList(id int, title string, metadata []components.Metadata, theme t rk = *m.RatingKey } if rk != "" { - if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { items[i].Poster = cached items[i].Loading = false } @@ -37,10 +40,11 @@ func NewPosterList(id int, title string, metadata []components.Metadata, theme t } return &PosterList{ - ID: id, - Title: title, - Items: items, - Theme: theme, + server: server, + ID: id, + Title: title, + Items: items, + Theme: theme, } } @@ -60,7 +64,7 @@ func (m *PosterList) SetItems(metadata []components.Metadata) tea.Cmd { rk = *meta.RatingKey } if rk != "" { - if cached, ok := artwork.Cached(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(m.server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -88,7 +92,7 @@ func (m *PosterList) fetchVisible() tea.Cmd { for i := start; i < end; i++ { if m.Items[i].Loading && !m.Items[i].Fetching { m.Items[i].Fetching = true - cmds = append(cmds, FetchPoster(m.ID, i, m.Items[i].Metadata)) + cmds = append(cmds, FetchPoster(m.ID, i, m.Items[i].Metadata, m.server)) } } return tea.Batch(cmds...) @@ -97,6 +101,9 @@ func (m *PosterList) fetchVisible() tea.Cmd { func (m *PosterList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case PosterLoadedMsg: + if msg.ServerID != "" && msg.ServerID != m.server.CacheNamespace() { + return m, nil + } if msg.ListID == m.ID && msg.Index >= 0 && msg.Index < len(m.Items) { if msg.RatingKey != ui.PtrToString(m.Items[msg.Index].Metadata.RatingKey) { return m, nil diff --git a/internal/tui/widget/resume/resume.go b/internal/tui/widget/resume/resume.go index 4a0659c..30ac54d 100644 --- a/internal/tui/widget/resume/resume.go +++ b/internal/tui/widget/resume/resume.go @@ -20,6 +20,7 @@ type ResumeDecisionMsg struct { } type ResumeOverlayModel struct { + server config.ServerTarget Metadata *components.Metadata Output config.VideoOutput theme tint.Tint @@ -29,8 +30,8 @@ type ResumeOverlayModel struct { options [2]ui.Rect } -func NewResumeOverlayModel(metadata *components.Metadata, output config.VideoOutput, theme tint.Tint) *ResumeOverlayModel { - return &ResumeOverlayModel{ +func NewResumeOverlayModel(metadata *components.Metadata, output config.VideoOutput, theme tint.Tint, servers ...config.ServerTarget) *ResumeOverlayModel { + return &ResumeOverlayModel{server: config.MediaServer(servers...), Metadata: metadata, Output: output, theme: theme, @@ -70,7 +71,7 @@ func (m *ResumeOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if resume && m.Metadata != nil && m.Metadata.ViewOffset != nil { offset = int64(*m.Metadata.ViewOffset) } - return nil, player.PlayMedia(m.Metadata, false, m.Output, offset) + return nil, player.PlayMedia(m.Metadata, false, m.Output, offset, m.server) case "esc": return nil, nil } diff --git a/internal/tui/widget/search/overlay.go b/internal/tui/widget/search/overlay.go index 9deb241..2c88987 100644 --- a/internal/tui/widget/search/overlay.go +++ b/internal/tui/widget/search/overlay.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/lipgloss" tint "github.com/lrstanley/bubbletint" "github.com/sahilm/fuzzy" + "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/search" "github.com/ygelfand/plexctl/internal/ui" ) @@ -34,6 +35,7 @@ func (i searchResultItem) Description() string { func (i searchResultItem) FilterValue() string { return i.entry.Title } type SearchOverlayModel struct { + server config.ServerTarget textInput textinput.Model list list.Model width int @@ -56,7 +58,8 @@ type resultsMsg struct { err error } -func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { +func NewSearchOverlayModel(theme tint.Tint, servers ...config.ServerTarget) *SearchOverlayModel { + server := config.MediaServer(servers...) ti := textinput.New() ti.Placeholder = "Artists, albums, tracks, movies..." ti.Focus() @@ -68,10 +71,12 @@ func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { l.SetFilteringEnabled(false) l.KeyMap.Quit.SetKeys("esc") - idx := search.GetIndex() - entries, _ := idx.Snapshot() + var entries []search.IndexEntry + if server.CacheNamespace() == config.Get().MediaCacheNamespace() { + entries, _ = search.GetIndex().Snapshot() + } - return &SearchOverlayModel{ + return &SearchOverlayModel{server: server, textInput: ti, list: l, theme: theme, @@ -91,7 +96,7 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.id != m.queryID { return m, nil } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(m.server.Context(context.Background()), 10*time.Second) m.cancel = cancel query, id := m.textInput.Value(), m.queryID return m, func() tea.Msg { @@ -133,7 +138,7 @@ func (m *SearchOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item := m.list.SelectedItem().(searchResultItem) m.Close() return nil, func() tea.Msg { - return ui.SelectMediaMsg{ + return ui.SelectMediaMsg{Server: m.server, RatingKey: item.entry.RatingKey, Type: item.entry.Type, SectionID: item.entry.SectionID, @@ -209,7 +214,7 @@ func (m *SearchOverlayModel) View() string { Padding(1, 2). Background(lipgloss.Color("#111111")) - status := "Search your entire library · [enter] Open · [esc] Back" + status := "Search " + m.server.Name + " · [enter] Open · [esc] Back" switch { case m.loading: status = "Searching Plex…" diff --git a/internal/ui/types.go b/internal/ui/types.go index 38637c1..eb25894 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -18,6 +18,7 @@ type HelpProvider interface { } type SelectMediaMsg struct { + Server config.ServerTarget RatingKey string Type string SectionID string @@ -32,6 +33,7 @@ func (m ThemeChangedMsg) GetTheme() tint.Tint { } type JumpToDetailMsg struct { + Server config.ServerTarget SectionID string RatingKey string Type string @@ -50,6 +52,7 @@ type SwitchUserMsg struct { type InvalidPinMsg struct{} type MediaPageMsg struct { + ServerID string SectionID string RequestID uint64 Start int @@ -73,11 +76,13 @@ type PlayableProvider interface { } type RequestPlayMsg struct { + Server config.ServerTarget RatingKey string Output config.VideoOutput } type ResumeChoiceMsg struct { + Server config.ServerTarget Metadata *components.Metadata Output config.VideoOutput } @@ -99,3 +104,8 @@ func PtrToString(p *string) string { } return *p } + +// ServerProvider identifies the server belonging to the current media selection. +type ServerProvider interface { + MediaServer() config.ServerTarget +} From 8e6f09938e4499da1eea442b49901e5450ac07ee Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:51:28 -0400 Subject: [PATCH 17/21] update README.md with PLEXCTL_TOKEN instructions --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 9a43a5d..03c5d90 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,51 @@ All CLI commands support the `-o` or `--output` flag to return data in `table`, ## Configuration +### Plex token (`PLEXCTL_TOKEN`) + +Set `PLEXCTL_TOKEN` to authenticate with Plex using an environment variable. It +takes precedence over the account token saved in your plexctl configuration. + +To find your token in the browser: + +1. Open [Plex Web](https://app.plex.tv/) and sign in to your Plex account. +2. Pick any movie or show from your own server. For a show, open an episode if + needed. +3. Open the **three-dot menu (⋯)** and choose **Get Info** near the bottom. +4. Click **View XML** at the bottom of the information window. +5. In the new browser tab, look near the end of the URL for `X-Plex-Token=`. + Copy only the value after `=`, stopping before the next `&` if present. Do not + include `X-Plex-Token=` or the rest of the URL. + +For example, if the URL ends with `?X-Plex-Token=YOUR_TOKEN`, copy `YOUR_TOKEN`. +The XML link is available when signed in as the server owner; see +[Plex's media information guide](https://support.plex.tv/articles/202462186-viewing-item-details/). + +On macOS or Linux with Bash or Zsh, replace the placeholder with your token: + +```bash +export PLEXCTL_TOKEN='YOUR_TOKEN' +plexctl server discover +plexctl +``` + +The export applies to the current terminal session. To keep it for future +sessions, add the same `export` line to `~/.zshrc` for Zsh or `~/.bashrc` for Bash, +then open a new terminal. + +On Windows with PowerShell, set it for the current session with: + +```powershell +$env:PLEXCTL_TOKEN = 'YOUR_TOKEN' +plexctl server discover +plexctl +``` + +Keep your token private. Plex describes tokens obtained this way as temporary; +if authentication stops working, retrieve a fresh token using the same steps. +See [Plex's token guide](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) +for more details. + ## License MIT From 0a5d21bb31b015926c2a53123f32e56390123141 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:26:39 -0400 Subject: [PATCH 18/21] feat: add complete music queues and terminal rendering options Queue full albums and artists with ordered per-track Plex reporting, cancellation, and reconnect support. Include soundtrack and compilation contributions. Add a poster renderer registry with Ghostty and Sixel support, live TUI video overlays, Sixel sizing, automatic text fallback, and selectable mpv music visualizers. Document settings and CLI flags, and cover playback, rendering, and shutdown with regression and real-mpv integration tests. --- README.md | 25 +- cmd/play.go | 31 +- cmd/root.go | 14 +- cmd/tui.go | 4 +- docs/content/cli/plexctl.md | 18 +- docs/content/cli/plexctl_collection.md | 16 +- docs/content/cli/plexctl_collection_list.md | 16 +- docs/content/cli/plexctl_collection_show.md | 16 +- docs/content/cli/plexctl_device.md | 16 +- docs/content/cli/plexctl_device_list.md | 16 +- docs/content/cli/plexctl_device_show.md | 16 +- docs/content/cli/plexctl_history.md | 16 +- docs/content/cli/plexctl_homeusers.md | 16 +- docs/content/cli/plexctl_homeusers_list.md | 16 +- docs/content/cli/plexctl_hub.md | 16 +- docs/content/cli/plexctl_hub_list.md | 16 +- docs/content/cli/plexctl_library.md | 16 +- docs/content/cli/plexctl_library_list.md | 16 +- docs/content/cli/plexctl_library_refresh.md | 16 +- docs/content/cli/plexctl_library_show.md | 16 +- docs/content/cli/plexctl_login.md | 16 +- docs/content/cli/plexctl_mpv.md | 16 +- docs/content/cli/plexctl_mpv_install.md | 16 +- docs/content/cli/plexctl_play.md | 16 +- docs/content/cli/plexctl_playlist.md | 16 +- docs/content/cli/plexctl_playlist_list.md | 16 +- docs/content/cli/plexctl_playlist_show.md | 16 +- docs/content/cli/plexctl_search.md | 16 +- docs/content/cli/plexctl_search_find.md | 16 +- docs/content/cli/plexctl_search_reindex.md | 16 +- docs/content/cli/plexctl_search_status.md | 16 +- docs/content/cli/plexctl_server.md | 16 +- docs/content/cli/plexctl_server_discover.md | 16 +- docs/content/cli/plexctl_server_list.md | 16 +- docs/content/cli/plexctl_server_status.md | 16 +- docs/content/cli/plexctl_server_use.md | 16 +- docs/content/cli/plexctl_session.md | 16 +- docs/content/cli/plexctl_session_list.md | 16 +- docs/content/cli/plexctl_session_show.md | 16 +- docs/content/cli/plexctl_session_stop.md | 16 +- docs/content/cli/plexctl_tasks.md | 16 +- docs/content/cli/plexctl_tasks_list.md | 16 +- docs/content/cli/plexctl_tasks_start.md | 16 +- docs/content/cli/plexctl_tasks_stop.md | 16 +- docs/content/cli/plexctl_tui.md | 16 +- docs/content/cli/plexctl_users.md | 16 +- docs/content/cli/plexctl_users_list.md | 16 +- docs/content/cli/plexctl_version.md | 16 +- docs/content/config.mdx | 98 ++- docs/content/tui.mdx | 67 ++- docs/eslint.config.mjs | 2 +- docs/fork-integration-plan.md | 101 ++++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 64 +- internal/config/video.go | 27 + internal/config/video_test.go | 56 +- internal/plex/media.go | 60 +- internal/plex/music.go | 207 +++++++ internal/plex/music_test.go | 108 ++++ internal/termgraphics/overlay.go | 203 +++++++ internal/termgraphics/overlay_test.go | 82 +++ internal/termgraphics/terminal.go | 21 +- internal/termgraphics/terminal_test.go | 8 + internal/tui/artwork/artwork.go | 190 +++--- internal/tui/artwork/artwork_test.go | 4 +- internal/tui/artwork/renderer.go | 218 +++++++ internal/tui/artwork/sixel.go | 320 ++++++++++ internal/tui/artwork/sixel_test.go | 110 ++++ internal/tui/music_player.go | 3 + internal/tui/player/actions.go | 158 +++-- internal/tui/player/actions_queue_test.go | 162 +++++ internal/tui/player/ipc.go | 41 ++ internal/tui/player/manager.go | 561 +++++++++++++++--- .../tui/player/manager_integration_test.go | 168 ++++++ internal/tui/player/manager_reporting_test.go | 236 ++++++++ internal/tui/player/manager_test.go | 45 ++ internal/tui/player/overlay.go | 88 +++ .../tui/player/overlay_integration_test.go | 145 +++++ internal/tui/player/player_state.go | 12 +- internal/tui/player/terminal.go | 259 ++++++-- .../tui/player/terminal_integration_test.go | 67 ++- .../player/terminal_queue_integration_test.go | 181 ++++++ internal/tui/player/video_output.go | 21 + internal/tui/player/visualizer.go | 22 + internal/tui/player/visualizer_test.go | 39 ++ internal/tui/tui.go | 11 +- internal/tui/view/detail/movie_detail.go | 2 +- internal/tui/view/detail/music_detail.go | 4 +- internal/tui/widget/poster/item.go | 8 +- internal/tui/widget/resume/resume.go | 3 +- internal/tui/widget/settings/settings.go | 44 ++ 92 files changed, 4259 insertions(+), 750 deletions(-) create mode 100644 docs/fork-integration-plan.md create mode 100644 internal/plex/music.go create mode 100644 internal/plex/music_test.go create mode 100644 internal/termgraphics/overlay.go create mode 100644 internal/termgraphics/overlay_test.go create mode 100644 internal/tui/artwork/renderer.go create mode 100644 internal/tui/artwork/sixel.go create mode 100644 internal/tui/artwork/sixel_test.go create mode 100644 internal/tui/player/actions_queue_test.go create mode 100644 internal/tui/player/manager_integration_test.go create mode 100644 internal/tui/player/manager_reporting_test.go create mode 100644 internal/tui/player/manager_test.go create mode 100644 internal/tui/player/overlay.go create mode 100644 internal/tui/player/overlay_integration_test.go create mode 100644 internal/tui/player/terminal_queue_integration_test.go create mode 100644 internal/tui/player/visualizer.go create mode 100644 internal/tui/player/visualizer_test.go diff --git a/README.md b/README.md index 03c5d90..6eb74bf 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ golangci-lint run golangci-lint run --build-tags integration ``` -The audio integration test uses a local mpv installation and skips when mpv is unavailable. +Player integration tests use synthetic media and a local mpv installation, and +skip when mpv is unavailable. `PLEXCTL_TEST_MPV=/path/to/mpv` selects an alternate +build for the overlay compatibility test. Pull requests run these checks and `govulncheck` in GitHub Actions. ## Features @@ -79,6 +81,10 @@ Pull requests run these checks and `govulncheck` in GitHub Actions. - **Fuzzy Search**: Find exactly what you're looking for instantly. - **Cross-Platform**: Supports macOS, Linux, and Windows. - **Flexible Output**: CLI commands support table, JSON, YAML, and CSV formats. +- **Complete music queues**: Play an artist or album with per-track progress, + including soundtrack and compilation contributions. +- **Terminal graphics**: Kitty/Ghostty and Sixel posters, suspend/overlay video, + and separate music visualizers for the TUI and mpv window. ## Usage @@ -113,10 +119,15 @@ plexctl play 12345 --terminal-video auto Graphics playback requires a compatible terminal and an mpv build that lists the chosen renderer in `mpv --vo=help`. plexctl probes the attached terminal, including through tmux, and uses text output when graphics are unavailable. In tmux 3.3+, -playback temporarily zooms its pane and restores the layout on exit. +suspended playback temporarily zooms its pane and restores the layout on exit. On Linux, a terminal that confirms local shared-memory access uses that faster transport; SSH and other terminals keep using portable graphics packets. +**Ctrl+S → Terminal Video Mode → Overlay** keeps the TUI and its player controls +active while video draws over the content pane. Use **Space**, **[ / ]**, +**+ / -**, **m**, and **x** to control playback. Sixel video defaults to a centered +640-pixel width cap; change it in Settings or with `--sixel-video-width`. + Mouse support is enabled automatically: click posters, navigation, and dialog options; scroll with the wheel; right click to go back. During terminal video, click to pause, scroll to seek, and right click to return. **V** switches library @@ -130,8 +141,9 @@ In Sessions, History, and Tasks, **j/k** select rows and **h/l** scroll wide tab left/right. The search Status page scrolls with **hjkl** too, including during indexing. Arrow keys provide the same navigation. -Movie and TV cover posters automatically use high-resolution images in Kitty -0.28+ (also through tmux), with text posters as a fallback in other terminals. +Movie and TV cover posters automatically use native images in Kitty 0.28+, +Ghostty, and terminals advertising Sixel (also through tmux), with half-cell +text as a fallback. **Ctrl+S → Poster Renderer** overrides automatic detection. See the [TUI guide](docs/content/tui.mdx) for configuration and playback details. Music libraries support artist → album → track navigation. Press **p** to play @@ -139,7 +151,10 @@ the selected song in the TUI, with a live stereo waveform and no extra window. **v** chooses terminal audio or a separate window; **Ctrl+S → Music Player** saves the default. Album covers appear in the artist gallery and beside the track list. Scrolling automatically loads more artists. Playing an artist or -album starts its first playable track. +album queues all its available tracks, updating the title and reporting each +track to Plex. Artist browsing includes soundtrack and compilation albums. +Windowed music offers **cqt**, **spectrum**, **waves**, and **scope** visualizations +under **Ctrl+S → Music Video Visualizer**, plus **off**. Click the right-hand **A–Z rail**, or press **g then a letter**, to jump through movies, TV shows, artists, albums, and track titles. Missing library pages load diff --git a/cmd/play.go b/cmd/play.go index 180c90c..2b710fb 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "os" + "os/signal" "path/filepath" "time" @@ -41,6 +43,10 @@ var playCmd = &cobra.Command{ cfg := config.Get() cfg.LogFile = filepath.Join(cfg.CacheDir, "player.log") cfg.SetupLogging() + } else { + var cancel context.CancelFunc + ctx, cancel = signal.NotifyContext(ctx, os.Interrupt) + defer cancel() } mediaID := args[0] slog.Debug("CLI Play: Fetching metadata", "mediaID", mediaID) @@ -54,14 +60,10 @@ var playCmd = &cobra.Command{ if trailer { slog.Debug("CLI Play: Resolving trailer", "mediaID", mediaID) - playFunc = player.FetchAndPlayTrailer(meta, output) + playFunc = player.FetchAndPlayTrailer(ctx, meta, output) } else { - meta, err = plex.ResolvePlayable(ctx, meta) - if err != nil { - return err - } offset := int64(0) - if !noResume && meta.ViewOffset != nil { + if !noResume && meta.Type != "artist" && meta.Type != "album" && meta.ViewOffset != nil { offset = int64(*meta.ViewOffset) } @@ -71,7 +73,7 @@ var playCmd = &cobra.Command{ slog.Info("Playing", "title", meta.Title, "type", meta.Type) } - playFunc = player.PlayMedia(meta, false, output, offset) + playFunc = player.PlayMedia(ctx, meta, false, output, offset) } if playFunc == nil { @@ -79,10 +81,18 @@ var playCmd = &cobra.Command{ } // Execute the playback function + pm := player.GetPlayerManager() + defer pm.Close() + defer func() { pm.StopPlayback()() }() msg := playFunc() if err, ok := msg.(error); ok { return err } + if fallback, ok := msg.(player.VideoFallbackMsg); ok { + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), fallback.String()); err != nil { + return err + } + } if output != config.VideoWindow { // Terminal playback owns the foreground until EOF or the user's q. return nil @@ -91,17 +101,18 @@ var playCmd = &cobra.Command{ if trailer { title = title + "(Trailer)" } - pm := player.GetPlayerManager() if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Playing %s. Press Ctrl+C to stop.\n", title); err != nil { return fmt.Errorf("write playback status: %w", err) } // Wait loop for CLI to keep progress reporting alive - for pm.VerifyConnection() { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for pm.Status().Active() { select { case <-ctx.Done(): return nil - case <-time.After(500 * time.Millisecond): + case <-ticker.C: // Just keep waiting } } diff --git a/cmd/root.go b/cmd/root.go index fb60052..ca8ec80 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,6 +77,18 @@ func init() { rootCmd.PersistentFlags().String("terminal-video", "auto", "Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct)") cobra.CheckErr(viper.BindPFlag("terminal_video", rootCmd.PersistentFlags().Lookup("terminal-video"))) playCmd.MarkFlagsMutuallyExclusive("tct", "terminal-video") + rootCmd.PersistentFlags().String("poster-renderer", "auto", "Poster renderer (auto, kitty, sixel, halfcell)") + rootCmd.PersistentFlags().String("terminal-video-mode", "suspend", "TUI terminal video mode (suspend, overlay)") + rootCmd.PersistentFlags().Int("sixel-video-width", 640, "Maximum Sixel video width in pixels (0 uses terminal size)") + rootCmd.PersistentFlags().String("window-music-visualizer", "cqt", "Music video visualization (cqt, spectrum, waves, scope, off)") + for _, binding := range []struct{ key, flag string }{ + {key: "poster_renderer", flag: "poster-renderer"}, + {key: "terminal_video_mode", flag: "terminal-video-mode"}, + {key: "sixel_video_width", flag: "sixel-video-width"}, + {key: "window_music_visualizer", flag: "window-music-visualizer"}, + } { + cobra.CheckErr(viper.BindPFlag(binding.key, rootCmd.PersistentFlags().Lookup(binding.flag))) + } cobra.CheckErr(rootCmd.RegisterFlagCompletionFunc("terminal-video", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return []string{"auto", "kitty", "sixel", "tct"}, cobra.ShellCompDirectiveNoFileComp })) @@ -117,7 +129,7 @@ func initConfig() { ui.RenderError(fmt.Errorf("failed to parse config: %w", err)) os.Exit(1) } - if _, err := config.ParseTerminalVideo(string(cfg.TerminalVideo)); err != nil { + if err := cfg.ValidatePlayback(); err != nil { ui.RenderError(err) os.Exit(1) } diff --git a/cmd/tui.go b/cmd/tui.go index cdca737..eeb1526 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -54,7 +54,7 @@ func runTUI(cmd *cobra.Command) (runErr error) { slog.Debug("Graphics passthrough unavailable", "error", err) } defer restore() - output, err := artwork.Open(os.Stdout, caps.NativePosters()) + output, err := artwork.OpenRenderer(os.Stdout, cfg.PosterRenderer, caps) if err != nil { return fmt.Errorf("initialize poster graphics: %w", err) } @@ -75,7 +75,7 @@ func runTUI(cmd *cobra.Command) (runErr error) { p := tea.NewProgram(tui.NewController(result), tea.WithAltScreen(), tea.WithOutput(output), tea.WithMouseCellMotion()) defer func() { pm := player.GetPlayerManager() - if pm.Status().AudioOnly || cfg.CloseVideoOnQuit { + if pm.Status().AudioOnly || pm.Status().Overlay || cfg.CloseVideoOnQuit { pm.StopPlayback()() } pm.Close() diff --git a/docs/content/cli/plexctl.md b/docs/content/cli/plexctl.md index 518b547..436603e 100644 --- a/docs/content/cli/plexctl.md +++ b/docs/content/cli/plexctl.md @@ -13,13 +13,17 @@ plexctl [flags] ### Options ``` - --config string config file (default is $HOME/.plexctl.yaml) - -h, --help help for plexctl - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + -h, --help help for plexctl + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection.md b/docs/content/cli/plexctl_collection.md index c91bf5d..122ab10 100644 --- a/docs/content/cli/plexctl_collection.md +++ b/docs/content/cli/plexctl_collection.md @@ -11,12 +11,16 @@ Manage collections ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection_list.md b/docs/content/cli/plexctl_collection_list.md index e48ff23..e28f69a 100644 --- a/docs/content/cli/plexctl_collection_list.md +++ b/docs/content/cli/plexctl_collection_list.md @@ -15,12 +15,16 @@ plexctl collection list [library_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_collection_show.md b/docs/content/cli/plexctl_collection_show.md index 42ab3c5..4237f50 100644 --- a/docs/content/cli/plexctl_collection_show.md +++ b/docs/content/cli/plexctl_collection_show.md @@ -15,12 +15,16 @@ plexctl collection show [collection_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device.md b/docs/content/cli/plexctl_device.md index e4beb76..66358ad 100644 --- a/docs/content/cli/plexctl_device.md +++ b/docs/content/cli/plexctl_device.md @@ -11,12 +11,16 @@ Manage account devices ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device_list.md b/docs/content/cli/plexctl_device_list.md index 5d59da3..bd87b75 100644 --- a/docs/content/cli/plexctl_device_list.md +++ b/docs/content/cli/plexctl_device_list.md @@ -15,12 +15,16 @@ plexctl device list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_device_show.md b/docs/content/cli/plexctl_device_show.md index 651a221..a0b311f 100644 --- a/docs/content/cli/plexctl_device_show.md +++ b/docs/content/cli/plexctl_device_show.md @@ -15,12 +15,16 @@ plexctl device show [device_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_history.md b/docs/content/cli/plexctl_history.md index 567d3d3..8e6434b 100644 --- a/docs/content/cli/plexctl_history.md +++ b/docs/content/cli/plexctl_history.md @@ -16,12 +16,16 @@ plexctl history [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_homeusers.md b/docs/content/cli/plexctl_homeusers.md index 36ef489..ffc62b7 100644 --- a/docs/content/cli/plexctl_homeusers.md +++ b/docs/content/cli/plexctl_homeusers.md @@ -11,12 +11,16 @@ Manage Plex Home users ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_homeusers_list.md b/docs/content/cli/plexctl_homeusers_list.md index 1e070c7..39ee6d4 100644 --- a/docs/content/cli/plexctl_homeusers_list.md +++ b/docs/content/cli/plexctl_homeusers_list.md @@ -15,12 +15,16 @@ plexctl homeusers list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_hub.md b/docs/content/cli/plexctl_hub.md index b56044d..a769b0c 100644 --- a/docs/content/cli/plexctl_hub.md +++ b/docs/content/cli/plexctl_hub.md @@ -11,12 +11,16 @@ Manage hubs ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_hub_list.md b/docs/content/cli/plexctl_hub_list.md index 0f21a1a..bcdcaf1 100644 --- a/docs/content/cli/plexctl_hub_list.md +++ b/docs/content/cli/plexctl_hub_list.md @@ -15,12 +15,16 @@ plexctl hub list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library.md b/docs/content/cli/plexctl_library.md index 57753bb..d66536e 100644 --- a/docs/content/cli/plexctl_library.md +++ b/docs/content/cli/plexctl_library.md @@ -11,12 +11,16 @@ Manage libraries ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_list.md b/docs/content/cli/plexctl_library_list.md index 8ecb34f..952b9c8 100644 --- a/docs/content/cli/plexctl_library_list.md +++ b/docs/content/cli/plexctl_library_list.md @@ -15,12 +15,16 @@ plexctl library list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_refresh.md b/docs/content/cli/plexctl_library_refresh.md index 6515812..7e0de27 100644 --- a/docs/content/cli/plexctl_library_refresh.md +++ b/docs/content/cli/plexctl_library_refresh.md @@ -15,12 +15,16 @@ plexctl library refresh [library_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_library_show.md b/docs/content/cli/plexctl_library_show.md index 964f994..5b03b24 100644 --- a/docs/content/cli/plexctl_library_show.md +++ b/docs/content/cli/plexctl_library_show.md @@ -18,12 +18,16 @@ plexctl library show [library_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_login.md b/docs/content/cli/plexctl_login.md index 455fd7c..87611b4 100644 --- a/docs/content/cli/plexctl_login.md +++ b/docs/content/cli/plexctl_login.md @@ -16,12 +16,16 @@ plexctl login [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_mpv.md b/docs/content/cli/plexctl_mpv.md index d2a970f..da1daab 100644 --- a/docs/content/cli/plexctl_mpv.md +++ b/docs/content/cli/plexctl_mpv.md @@ -15,12 +15,16 @@ plexctl mpv [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_mpv_install.md b/docs/content/cli/plexctl_mpv_install.md index 24568d3..84683ca 100644 --- a/docs/content/cli/plexctl_mpv_install.md +++ b/docs/content/cli/plexctl_mpv_install.md @@ -15,12 +15,16 @@ plexctl mpv install [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_play.md b/docs/content/cli/plexctl_play.md index bb43565..285e706 100644 --- a/docs/content/cli/plexctl_play.md +++ b/docs/content/cli/plexctl_play.md @@ -18,12 +18,16 @@ plexctl play [media_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist.md b/docs/content/cli/plexctl_playlist.md index 85ca57c..eabaadf 100644 --- a/docs/content/cli/plexctl_playlist.md +++ b/docs/content/cli/plexctl_playlist.md @@ -11,12 +11,16 @@ Manage playlists ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist_list.md b/docs/content/cli/plexctl_playlist_list.md index b1b2feb..3a0fa4b 100644 --- a/docs/content/cli/plexctl_playlist_list.md +++ b/docs/content/cli/plexctl_playlist_list.md @@ -15,12 +15,16 @@ plexctl playlist list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_playlist_show.md b/docs/content/cli/plexctl_playlist_show.md index 4c7d81e..f3d131c 100644 --- a/docs/content/cli/plexctl_playlist_show.md +++ b/docs/content/cli/plexctl_playlist_show.md @@ -15,12 +15,16 @@ plexctl playlist show [playlist_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search.md b/docs/content/cli/plexctl_search.md index 61e2f3e..945785c 100644 --- a/docs/content/cli/plexctl_search.md +++ b/docs/content/cli/plexctl_search.md @@ -11,12 +11,16 @@ Manage and use the library search index ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_find.md b/docs/content/cli/plexctl_search_find.md index eb2535e..53966a7 100644 --- a/docs/content/cli/plexctl_search_find.md +++ b/docs/content/cli/plexctl_search_find.md @@ -15,12 +15,16 @@ plexctl search find [query] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_reindex.md b/docs/content/cli/plexctl_search_reindex.md index 5a2fd41..dadc31f 100644 --- a/docs/content/cli/plexctl_search_reindex.md +++ b/docs/content/cli/plexctl_search_reindex.md @@ -15,12 +15,16 @@ plexctl search reindex [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_search_status.md b/docs/content/cli/plexctl_search_status.md index 872e9a9..c771fb5 100644 --- a/docs/content/cli/plexctl_search_status.md +++ b/docs/content/cli/plexctl_search_status.md @@ -15,12 +15,16 @@ plexctl search status [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server.md b/docs/content/cli/plexctl_server.md index 2a82414..a250c4e 100644 --- a/docs/content/cli/plexctl_server.md +++ b/docs/content/cli/plexctl_server.md @@ -11,12 +11,16 @@ Manage Plex Server ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_discover.md b/docs/content/cli/plexctl_server_discover.md index 6eb1679..6ab41a6 100644 --- a/docs/content/cli/plexctl_server_discover.md +++ b/docs/content/cli/plexctl_server_discover.md @@ -15,12 +15,16 @@ plexctl server discover [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_list.md b/docs/content/cli/plexctl_server_list.md index a71dfef..6ba34a5 100644 --- a/docs/content/cli/plexctl_server_list.md +++ b/docs/content/cli/plexctl_server_list.md @@ -15,12 +15,16 @@ plexctl server list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_status.md b/docs/content/cli/plexctl_server_status.md index dba40b5..9092ab8 100644 --- a/docs/content/cli/plexctl_server_status.md +++ b/docs/content/cli/plexctl_server_status.md @@ -15,12 +15,16 @@ plexctl server status [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_server_use.md b/docs/content/cli/plexctl_server_use.md index 6e08f24..5836a6f 100644 --- a/docs/content/cli/plexctl_server_use.md +++ b/docs/content/cli/plexctl_server_use.md @@ -15,12 +15,16 @@ plexctl server use [name_or_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session.md b/docs/content/cli/plexctl_session.md index af9df74..41b222a 100644 --- a/docs/content/cli/plexctl_session.md +++ b/docs/content/cli/plexctl_session.md @@ -11,12 +11,16 @@ Manage active playback sessions ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_list.md b/docs/content/cli/plexctl_session_list.md index d8d1fb9..fbf1f31 100644 --- a/docs/content/cli/plexctl_session_list.md +++ b/docs/content/cli/plexctl_session_list.md @@ -15,12 +15,16 @@ plexctl session list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_show.md b/docs/content/cli/plexctl_session_show.md index 36c6cde..f4bba0e 100644 --- a/docs/content/cli/plexctl_session_show.md +++ b/docs/content/cli/plexctl_session_show.md @@ -15,12 +15,16 @@ plexctl session show [session_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_session_stop.md b/docs/content/cli/plexctl_session_stop.md index e1530c4..5a6e0ed 100644 --- a/docs/content/cli/plexctl_session_stop.md +++ b/docs/content/cli/plexctl_session_stop.md @@ -15,12 +15,16 @@ plexctl session stop [session_id] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks.md b/docs/content/cli/plexctl_tasks.md index 82f926b..c1ed316 100644 --- a/docs/content/cli/plexctl_tasks.md +++ b/docs/content/cli/plexctl_tasks.md @@ -11,12 +11,16 @@ Manage background butler tasks ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_list.md b/docs/content/cli/plexctl_tasks_list.md index c38995f..72d3a72 100644 --- a/docs/content/cli/plexctl_tasks_list.md +++ b/docs/content/cli/plexctl_tasks_list.md @@ -15,12 +15,16 @@ plexctl tasks list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_start.md b/docs/content/cli/plexctl_tasks_start.md index c7873d0..87192b6 100644 --- a/docs/content/cli/plexctl_tasks_start.md +++ b/docs/content/cli/plexctl_tasks_start.md @@ -15,12 +15,16 @@ plexctl tasks start [task_name] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tasks_stop.md b/docs/content/cli/plexctl_tasks_stop.md index 627b7d2..2903cdb 100644 --- a/docs/content/cli/plexctl_tasks_stop.md +++ b/docs/content/cli/plexctl_tasks_stop.md @@ -15,12 +15,16 @@ plexctl tasks stop [task_name] [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_tui.md b/docs/content/cli/plexctl_tui.md index b166635..71f5832 100644 --- a/docs/content/cli/plexctl_tui.md +++ b/docs/content/cli/plexctl_tui.md @@ -15,12 +15,16 @@ plexctl tui [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_users.md b/docs/content/cli/plexctl_users.md index 6dd169f..e6f9304 100644 --- a/docs/content/cli/plexctl_users.md +++ b/docs/content/cli/plexctl_users.md @@ -11,12 +11,16 @@ Manage Plex users ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_users_list.md b/docs/content/cli/plexctl_users_list.md index 5a119a0..94c46f2 100644 --- a/docs/content/cli/plexctl_users_list.md +++ b/docs/content/cli/plexctl_users_list.md @@ -15,12 +15,16 @@ plexctl users list [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/cli/plexctl_version.md b/docs/content/cli/plexctl_version.md index 4ff972f..1342853 100644 --- a/docs/content/cli/plexctl_version.md +++ b/docs/content/cli/plexctl_version.md @@ -15,12 +15,16 @@ plexctl version [flags] ### Options inherited from parent commands ``` - --config string config file (default is $HOME/.plexctl.yaml) - --no-cache Disable caching - -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") - --sort string column to sort by - --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") - -v, --verbose count increase verbosity + --config string config file (default is $HOME/.plexctl.yaml) + --no-cache Disable caching + -o, --output string Output format (table, json, json-pretty, yaml, csv, txt) (default "table") + --poster-renderer string Poster renderer (auto, kitty, sixel, halfcell) (default "auto") + --sixel-video-width int Maximum Sixel video width in pixels (0 uses terminal size) (default 640) + --sort string column to sort by + --terminal-video string Terminal video renderer for Ctrl+P or play (auto, kitty, sixel, tct) (default "auto") + --terminal-video-mode string TUI terminal video mode (suspend, overlay) (default "suspend") + -v, --verbose count increase verbosity + --window-music-visualizer string Music video visualization (cqt, spectrum, waves, scope, off) (default "cqt") ``` ### SEE ALSO diff --git a/docs/content/config.mdx b/docs/content/config.mdx index ce0a008..f211567 100644 --- a/docs/content/config.mdx +++ b/docs/content/config.mdx @@ -17,6 +17,12 @@ library_name_format: "icon_name" # Default: icon_name default_view_mode: "poster" # Default: poster close_video_on_quit: true # Default: false terminal_video: "auto" # auto, kitty, sixel, tct +terminal_video_mode: "suspend" # suspend or overlay (TUI only) +sixel_video_width: 640 # Pixel cap; 0 uses the terminal size +poster_renderer: "auto" # auto, kitty, sixel, halfcell +music_player: "terminal" # terminal or window +music_visualizer: "waveform" # waveform, spectrum, mirror, off +window_music_visualizer: "cqt" # cqt, spectrum, waves, scope, off cache_dir: "/home/user/.plexctl/cache" # Default: ~/.plexctl/cache default_server: "d9e8f7a6b5c4d3e2f1a0" servers: @@ -70,6 +76,21 @@ The initial layout used when browsing a library. - **Default:** `poster` - **Options:** `poster` (grid), `list` (table). +### `poster_renderer` +Selects how movie/TV posters and album covers are drawn. Covers retain their +aspect ratio inside the available space. + +- **Default:** `auto` +- **Options:** `auto`, `kitty`, `sixel`, `halfcell`. +- **TUI:** **Ctrl+S → Poster Renderer**, applied when Settings closes. +- **Override:** `--poster-renderer sixel` or `PLEXCTL_POSTER_RENDERER=sixel`. + +Automatic selection prefers Kitty Unicode placeholders in Kitty 0.28+ and +Ghostty when the graphics probe succeeds, then Sixel when advertised, then +colored half-cell text. Explicit selection lets you override detection. +Kitty video support alone does not imply Unicode placeholder support. +Native graphics also use tmux passthrough; text works without graphics support. + --- ## Playback & Cache @@ -77,7 +98,7 @@ The initial layout used when browsing a library. Settings related to how media is played and how data is stored locally. ### `terminal_video` -Selects the renderer used by **Ctrl+P** for full-terminal video playback. +Selects the renderer used by **Ctrl+P** for terminal video playback. Press **v** to choose a renderer for an individual playback. - **Default:** `auto` - **Options:** `auto`, `kitty`, `sixel`, `tct`. @@ -85,19 +106,84 @@ Press **v** to choose a renderer for an individual playback. - **Override:** `--terminal-video kitty` or `PLEXCTL_TERMINAL_VIDEO=kitty`. Auto mode probes the attached terminal, checks which outputs mpv provides, -and falls back to TCT. Graphics require support in both the terminal and mpv; +and falls back to TCT. If an automatically chosen graphics output fails during +startup, it retries with TCT and shows a notice. Explicit choices report the +failure. Graphics require support in both the terminal and mpv; run `mpv --vo=help` to check the latter. tmux 3.3+ is supported automatically; unknown terminals use TCT. Plain `p` continues to open a video window. For CLI terminal playback, pass `plexctl play --terminal-video ` explicitly. -Terminal playback ends on **q** or at the end of the video and returns to the -library, independently of `close_video_on_quit`. +Suspended terminal playback ends on **q** or at the end of the queue and returns +to the library, independently of `close_video_on_quit`. + +### `terminal_video_mode` +Controls how terminal video shares the TUI. + +- **Default:** `suspend` +- **Options:** `suspend` takes over the terminal with mpv playback controls; + `overlay` draws over the content pane while the sidebar and TUI player remain + active. Dialogs temporarily hide overlay video. +- **TUI:** **Ctrl+S → Terminal Video Mode**. +- **Override:** `--terminal-video-mode overlay` or `PLEXCTL_TERMINAL_VIDEO_MODE=overlay`. + +Overlay mode uses the TUI's **Space**, **[ / ]**, **+ / -**, **m**, and **x** +controls. It stops on TUI exit. CLI terminal playback always uses suspend mode. +This setting does not change terminal audio playback or a separate mpv window. + +### `sixel_video_width` +Caps Sixel video width in pixels and centers the result within the playback +area. A smaller value reduces the amount of image data sent each frame. + +- **Default:** `640` +- **Values:** a positive pixel width, or `0` for mpv's automatic sizing. +- **TUI:** **Ctrl+S → Sixel Video Width** offers 320, 480, 640, 960, 1280, or automatic. +- **Override:** `--sixel-video-width 480` or `PLEXCTL_SIXEL_VIDEO_WIDTH=480`. + +The cap applies to suspend and overlay video, including music visualization. +It does not affect posters, Kitty video, or text video. + +### `music_player` +Chooses where **p** plays a track, album, or artist in the TUI. + +- **Default:** `terminal` +- **Options:** `terminal` plays audio with the TUI player; `window` opens mpv. +- **TUI:** **Ctrl+S → Music Player**, or **v** for a single selection. +- **Environment:** `PLEXCTL_MUSIC_PLAYER`. + +**Ctrl+P** always selects terminal audio. Playing an album queues all its tracks; +playing an artist queues their tracks, including compilation contributions. + +### `music_visualizer` +Controls the audio-only TUI display. Playback continues while you browse. + +- **Default:** `waveform` +- **Options:** `waveform`, `spectrum`, `mirror`, `off` (compact controls). +- **TUI:** **Ctrl+S → Music Visualizer**, or **z** while music is playing. +- **Environment:** `PLEXCTL_MUSIC_VISUALIZER`. + +### `window_music_visualizer` +Creates video from music when using a separate mpv window or explicit terminal +video, including `plexctl play --terminal-video auto`. + +- **Default:** `cqt` +- **Options:** `cqt` (frequency visualization), `spectrum` (scrolling spectrum), + `waves` (waveform), `scope` (stereo vectorscope), `off`. +- **TUI:** **Ctrl+S → Music Video Visualizer**. +- **Override:** `--window-music-visualizer waves` or `PLEXCTL_WINDOW_MUSIC_VISUALIZER=waves`. + +This uses mpv's FFmpeg/lavfi filters and preserves the audible audio stream. +Choose `off` if your mpv build lacks the selected filter. The setting applies to +the next playback request; `music_visualizer` controls the separate TUI display. ### `close_video_on_quit` When exiting `plexctl`, determine if the `mpv` video window should also be closed. - **Default:** `false` - `true`: `plexctl` sends a quit command to `mpv` on exit. -- `false`: `mpv` remains open and continues playing. +- `false`: the separate mpv window can continue playing after the TUI exits. + +Terminal audio and overlay video always stop when the TUI exits. CLI playback +exits when the queue finishes; Ctrl+C stops it. Per-track Plex reporting requires +plexctl to remain running. ### `cache_dir` The filesystem path where search indexes, metadata, and images are cached to improve performance. @@ -151,6 +237,8 @@ Pressing **`ctrl+s`** at any time opens the Global Settings overlay. From here y - **Change Theme:** Cycle through available color palettes (Nord, Dracula, etc.) and see changes instantly. - **Set Icon Type:** Switch between Nerd Fonts, Emojis, and ASCII icons. - **Sidebar Layout:** Adjust the `library_name_format` to show or hide icons and names. +- **Playback and Images:** Choose poster/video renderers, suspend/overlay mode, + Sixel width, music player, and both music visualizers. ### Library Management (`L`) Pressing **`L`** opens the Library Configuration overlay. This allows you to manage the appearance of your sidebar: diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 92921f8..6346151 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -32,7 +32,7 @@ Mouse support is enabled automatically, including inside tmux: an open dialog do not activate the library behind it. - Hold **Shift** while selecting text to use the terminal's own selection. -During terminal playback, **left click** pauses/resumes, **wheel up/down** seeks +During suspended terminal playback, **left click** pauses/resumes, **wheel up/down** seeks forward/backward, and **right click** stops and returns to the library. These use mpv's Space, Right/Left, and q bindings, including any custom bindings you set. @@ -105,12 +105,21 @@ Pressing `L` opens the configuration manager. Here you can: Music libraries open **artists → albums → tracks**. Click an item or press **Enter** to browse, then press **`p`** to play the selected track. Playing an -artist or album directly starts its first playable track; open the album to -choose another song. **Esc** or right click returns to the previous level. +album queues the complete album in Plex's track order; playing an artist queues +all their available tracks. The title and progress follow each track, and Plex +receives each track's play progress and completion. Stopping playback or choosing +another item cancels a queue that is still loading. **Esc** or right click returns +to the previous browsing level. + +Artist pages include soundtracks and compilations where the artist contributes +tracks, using Plex's section/artist queries. Queues and progress reporting retain +the originating server and credentials, including Friends Libraries. Artist and +album queue playback also works with `plexctl play `. The artist page displays a grid of album covers with titles and years. Album art also appears beside the track list after selecting an album. It uses native -high-resolution images in Kitty and text images elsewhere, and resizes with the pane. +high-resolution images in Kitty, Ghostty, and Sixel terminals, with text as a +fallback, and resizes with the pane. Library scrolling loads additional artists automatically with either the mouse wheel or keyboard, in both poster and list views. A failed page can be retried with `r` without discarding the artists already loaded. @@ -168,6 +177,12 @@ The waveform requires mpv with Lua and FFmpeg/lavfi (`showwaves`); the spectrum requires its lavfi audio filters. Music playback remains available in minimal builds without visualization support. Terminal music stops when you quit the TUI. +Music played in a separate mpv window has its own video visualizer. Choose +**Ctrl+S → Music Video Visualizer**: **cqt**, **spectrum**, **waves**, **scope**, or +**off**. This also applies when explicitly playing music through terminal video +from the CLI. It requires the corresponding FFmpeg/lavfi filter in mpv. **z** +continues to control the audio-only TUI visualizer. + When playback is started, a playback bar appears at the bottom showing: - Current progress and duration. - Play/Pause status. @@ -180,9 +195,17 @@ player bar to pause/resume, and `x` or the footer's **Stop** action to stop. Press **`v`** on a movie or episode to open the terminal playback menu. Select **Kitty graphics**, **Sixel graphics**, **Text**, or **Automatic**. The menu shows the detected terminal capabilities. **`ctrl+p`** uses the preference saved in -Settings. Playback takes over the full terminal; the library view returns when +Settings. By default playback takes over the full terminal; the library view returns when the video ends or you press **`q`**. +Choose **Ctrl+S → Terminal Video Mode → Overlay** to draw video over the content +pane while keeping the TUI active. The sidebar, settings, and player controls +remain available. Use **Space** to pause, **[ / ]** to seek, **+ / -** for volume, +**m** to mute, and **x** to stop. Dialogs temporarily hide video and closing them +restores it, including paused frames. Quitting the TUI stops overlay playback. +Overlay uses these TUI controls; the on-video controls below belong to suspend +mode. CLI terminal playback always uses suspend mode. + Kitty video has a control bar drawn into the image, with elapsed/total time, play/pause, **-10s / +10s**, a clickable and draggable timeline, volume, mute, subtitle visibility (**CC**), playback speed, and **Return**. The buttons @@ -192,7 +215,7 @@ Press **`h`** to hide or show the controls. In Kitty, **Esc** also returns to th library and **+ / -** adjust volume. Scrolling over the volume controls adjusts volume; scrolling elsewhere seeks. -During playback, these shortcuts are available in every terminal renderer: +During suspended playback, these shortcuts are available in every terminal renderer: - **`Space`** or **left click on the picture**: Pause or resume. - **`Left` / `Right`** or **wheel**: Seek backward or forward. @@ -202,8 +225,8 @@ During playback, these shortcuts are available in every terminal renderer: - **`q`** or **right click**: Stop and return to the library. The resume prompt, external subtitle tracks, and Plex watch progress also work -with terminal playback. The library's `x` shortcut applies to windowed playback; -use `q` while terminal video is playing. +with terminal playback. Use **x** for windowed or overlay playback and **q** for +suspended playback. Choose **Terminal Video** in **`ctrl+s`** Settings: @@ -218,13 +241,15 @@ Auto detection queries the attached terminal, including through tmux. Kitty is preferred when supported, followed by Sixel, then TCT. If a terminal does not answer, known terminal environment variables are used outside multiplexers. Check your mpv build with **`mpv --vo=help`**. An explicitly selected renderer -that is missing from mpv produces an error instead of silently changing output. +that is missing from mpv produces an error. If an automatically selected graphics +output fails during startup, playback retries with Text and shows a notice. You can override the preference for a launch or play directly from the CLI: ```bash plexctl tui --terminal-video kitty plexctl tui --terminal-video sixel +plexctl tui --terminal-video-mode overlay --sixel-video-width 480 plexctl play 12345 --terminal-video auto plexctl play 12345 --terminal-video sixel --no-resume ``` @@ -235,7 +260,7 @@ open a video window. The existing `plexctl play 12345 --tct` explicitly selects Kitty output uses escape-code transport, so it does not require shared memory with the terminal when using SSH. With tmux 3.3+, plexctl temporarily enables -graphics passthrough for its own pane. During graphics playback it zooms that +graphics passthrough for its own pane. During suspended graphics playback it zooms that pane so video cannot cover neighboring panes, then restores the layout and passthrough setting on exit. Older mpv releases are supported by forwarding graphics and input through plexctl. GNU screen uses TCT in auto mode. @@ -245,14 +270,24 @@ launch plexctl (or attach tmux) from the Kitty application. See [mpv's video output documentation](https://mpv.io/manual/stable/#video-output-drivers) for terminal requirements and configuration. +Sixel video defaults to a centered **640-pixel width cap** to reduce frame cost +on wide terminals. Change **Ctrl+S → Sixel Video Width**, use +`--sixel-video-width 480`, or set it to **0** for automatic sizing. Overlay mode +uses the content pane's dimensions and does not zoom tmux panes. + ### High-resolution cover posters -In Kitty 0.28+, using Unicode image placeholders, movie -and TV posters and album covers automatically use native images, retaining up to 768 pixels on -their longest side instead of reducing the cover to 15 colored text cells. -This works in grids, details, and through tmux. Posters scroll and clip with the -TUI and are restored after video playback. Other terminals retain text posters. -No cache clearing or configuration change is required. +Poster selection is automatic: Kitty Unicode placeholders in **Kitty 0.28+** +and **Ghostty**, then **Sixel** if the terminal advertises it, then half-cell +text. Movie/TV posters and album covers keep their proportions, including square +album art. Kitty retains up to 768 pixels on the longest side and bounds live +terminal images; Sixel fits the actual cells and crops at viewport edges. + +Posters work in grids and details, with clipping, repeated covers, dialog redraws, +and restoration after terminal playback. tmux graphics use passthrough. Choose +**Ctrl+S → Poster Renderer** or `--poster-renderer` to force `kitty`, `sixel`, or +`halfcell`. Return to `auto` for capability-based selection. Sixel must be enabled +in the terminal; a forced protocol still requires terminal support. ### User Switching (Plex Home) Pressing `u` opens the "Who's watching?" profile picker. This allows you to switch between managed users in your Plex Home. diff --git a/docs/eslint.config.mjs b/docs/eslint.config.mjs index eb619da..03da6b7 100644 --- a/docs/eslint.config.mjs +++ b/docs/eslint.config.mjs @@ -4,7 +4,7 @@ import nextPlugin from "@next/eslint-plugin-next"; export default [ { - ignores: [".next/**", "node_modules/**", "dist/**"], + ignores: [".next/**", "node_modules/**", "dist/**", "out/**"], }, js.configs.recommended, { diff --git a/docs/fork-integration-plan.md b/docs/fork-integration-plan.md new file mode 100644 index 0000000..0f21f38 --- /dev/null +++ b/docs/fork-integration-plan.md @@ -0,0 +1,101 @@ +# Fork integration plan + +Source: `/home/phil/plexctl-fork-comparison.md`, verified against +`brijohn-plexctl` at `c734407` and this repository at `8e6f099`. +The report calls the friend's fork “ours” and this fork “Astra”. + +## Scope and decisions + +- Keep this fork's multi-server credentials/cache isolation, mouse and keyboard + navigation, tmux transport, terminal controls, audio visualizer, and CI. +- Adopt complete artist/album queues and per-track titles and Plex timeline + reporting in both CLI and TUI playback. Preserve track order, pagination, + cancellation, no-report playback, and the originating server. +- Include soundtrack/compilation albums and tracks with section `artist.id` + queries, with children traversal when section metadata is unavailable. +- Introduce a poster renderer interface and capability-ordered registry. Keep + the existing bounded Kitty upload cache and renderer-owned output; add + Ghostty placeholder detection, explicit renderer selection, aspect-aware + sizing, and Sixel posters with clipping and duplicate placements. +- Add selectable suspended/overlay terminal video, a centered Sixel pixel-width + cap, and fallback to text when automatic graphics playback fails at startup. +- Add separate window/terminal-video music visualizer settings (CQT, spectrum, + waves, scope, off), preserving the existing in-TUI visualizer settings. +- Document all new and existing playback/rendering settings and shortcuts. + +## Evidence corrections + +The supplied friend's checkout has no Sixel poster renderer or Sixel width +setting, despite the report describing them. Implement these directly as useful +gaps identified by the comparison. Album covers already exist here. Avoid +porting the friend's unsynchronized player state, authenticated-URL logging, +environment-only terminal probe, or fixed-version mpv argument guesses. + +## Implementation sequence + +1. Add paginated artist queries and queue resolution; wire browsing and playback. +2. Track playlist transitions and report each track, including final completion; + cover both persistent and foreground players with local IPC/HTTP tests. +3. Add renderer selection and Sixel composition; verify clipping, cleanup, + duplicate images, and existing Kitty cache behavior. +4. Add overlay playback, Sixel sizing, visualizer graphs, and settings/CLI wiring. +5. Update docs and run formatting, race/shuffle tests, real-mpv integration tests, + lint, builds, module consistency, and the vulnerability check. + +## Validation boundaries + +Tests use local fixtures and synthetic media, without real Plex credentials. +Live appearance and performance across physical Kitty, Ghostty, Sixel terminals, +and tmux require interactive acceptance testing on those terminals. + +## Progress + +- All five implementation steps are complete. Configuration, TUI Settings, CLI + flags, generated command pages, README, and the config/TUI guides are updated. +- Album/artist queues retain playlist-entry identity, resume only the first + track, preserve external subtitles, and report individual completions in + order. Stop/new selection cancels pending resolution. Regression coverage + includes 20 ms tracks, repeated file paths, reconnect, events during IPC + sampling, delayed HTTP, and bounded shutdown. Foreground queues drain IPC + acknowledgements as they are built, avoiding backpressure on large queues. +- Poster renderers preserve the existing Kitty disk cache and bounded terminal + textures. Sixel tests cover duplicate placements, clipped rows/columns, + partial six-pixel bands, dialogs, unchanged frames, and cleanup. Capability + tests cover Ghostty and terminal cell dimensions. +- Kitty, Sixel, and Text overlays were exercised with real mpv and synthetic + audio visualization, including pause, dialog hiding/restoration, and cleanup. + Automatic graphics startup failure falls back to Text with a notice; forced + output failures remain errors. All four music video graphs produce both + audio and video in real mpv. + +## Final validation + +- `go build ./...` and a Windows/amd64 cross-build pass. +- `PLEXCTL_TEST_MPV=/usr/bin/mpv go test -race -shuffle=on -count=1 -tags=integration ./...` + passes. Local HTTP/IPC fixtures use temporary caches and synthetic media. +- Both normal and integration-tagged `golangci-lint run` pass with zero issues; + `gopls check` reports no diagnostics in the changed core files. +- `go mod verify`, `go mod tidy -diff`, and `git diff --check` pass. +- Go 1.27-compatible `govulncheck` reports no reachable vulnerabilities. It also + reports 13 module-level advisories in code that this application does not call. +- The documentation site builds all 49 pages. ESLint passes with one existing + `img` optimization warning in `docs/app/layout.jsx`; generated `out/` files + are now excluded from linting. + +## Local compatibility finding and manual acceptance + +The development mpv on this machine's PATH identifies itself as +`v0.41.0-1150-g88eb9f2e7`. It aborts inside `vo_sixel.c: slice_dither` with a +palette-size assertion, reproducible by running mpv directly on synthetic audio +without plexctl. Changing palette options did not resolve it. The independent +system `/usr/bin/mpv` 0.37.0 passes the same Sixel overlay test. Neither installed +player was modified. `PLEXCTL_TEST_MPV` selects the player for the overlay +integration test; other tests continue to use mpv on PATH. + +Physical appearance and performance still need interactive checks on Kitty, +Ghostty, a Sixel terminal, and tmux: browse and clip posters, open/close dialogs, +switch poster renderers, resize paused overlay video, and return from suspended +playback. Headless protocol and real-player tests cannot establish visual quality +on those terminal emulators. Live Plex library and watch-history acceptance was +not performed; local fixtures validate pagination, server credentials, and +timeline requests without accessing the user's media. diff --git a/go.mod b/go.mod index 4530ffb..a98d64f 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect diff --git a/go.sum b/go.sum index 0c66634..80f3330 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= diff --git a/internal/config/config.go b/internal/config/config.go index 63e9e23..7135373 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -104,22 +104,26 @@ type HomeUser struct { // Config holds the global configuration for plexctl type Config struct { // Global settings - Token string `mapstructure:"token"` - HomeUser HomeUser `mapstructure:"home_user"` - OutputFormat string `mapstructure:"output"` - Verbosity int `mapstructure:"verbose"` - Theme string `mapstructure:"theme"` - IconType IconType `mapstructure:"icon_type"` // ascii, emoji, nerdfonts - LibraryNameFormat LibraryNameFormat `mapstructure:"library_name_format"` // icon_only, icon_name, name_icon, name - DefaultViewMode ViewMode `mapstructure:"default_view_mode"` // list, poster - CacheDir string `mapstructure:"cache_dir"` - NoCache bool `mapstructure:"no_cache"` - DefaultToTui bool `mapstructure:"default_to_tui"` - AutoHomeLogin bool `mapstructure:"auto_home_login"` - CloseVideoOnQuit bool `mapstructure:"close_video_on_quit"` - TerminalVideo VideoOutput `mapstructure:"terminal_video"` - MusicPlayer string `mapstructure:"music_player"` - MusicVisualizer string `mapstructure:"music_visualizer"` + Token string `mapstructure:"token"` + HomeUser HomeUser `mapstructure:"home_user"` + OutputFormat string `mapstructure:"output"` + Verbosity int `mapstructure:"verbose"` + Theme string `mapstructure:"theme"` + IconType IconType `mapstructure:"icon_type"` // ascii, emoji, nerdfonts + LibraryNameFormat LibraryNameFormat `mapstructure:"library_name_format"` // icon_only, icon_name, name_icon, name + DefaultViewMode ViewMode `mapstructure:"default_view_mode"` // list, poster + CacheDir string `mapstructure:"cache_dir"` + NoCache bool `mapstructure:"no_cache"` + DefaultToTui bool `mapstructure:"default_to_tui"` + AutoHomeLogin bool `mapstructure:"auto_home_login"` + CloseVideoOnQuit bool `mapstructure:"close_video_on_quit"` + TerminalVideo VideoOutput `mapstructure:"terminal_video"` + TerminalVideoMode string `mapstructure:"terminal_video_mode"` + SixelVideoWidth int `mapstructure:"sixel_video_width"` + PosterRenderer string `mapstructure:"poster_renderer"` + WindowMusicVisualizer string `mapstructure:"window_music_visualizer"` + MusicPlayer string `mapstructure:"music_player"` + MusicVisualizer string `mapstructure:"music_visualizer"` // Server management DefaultServer string `mapstructure:"default_server"` // Stores the ClientIdentifier @@ -149,16 +153,20 @@ func Get() *Config { lvl := &slog.LevelVar{} lvl.Set(slog.LevelInfo) instance = &Config{ - Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})), - LogLevel: lvl, - Servers: make(map[string]Server), - CacheDir: filepath.Join(home, ".plexctl", "cache"), - DefaultToTui: true, - AutoHomeLogin: true, - DefaultViewMode: ViewModePoster, - TerminalVideo: VideoAuto, - MusicPlayer: "terminal", - MusicVisualizer: "waveform", + Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})), + LogLevel: lvl, + Servers: make(map[string]Server), + CacheDir: filepath.Join(home, ".plexctl", "cache"), + DefaultToTui: true, + AutoHomeLogin: true, + DefaultViewMode: ViewModePoster, + TerminalVideo: VideoAuto, + TerminalVideoMode: "suspend", + SixelVideoWidth: 640, + PosterRenderer: "auto", + WindowMusicVisualizer: "cqt", + MusicPlayer: "terminal", + MusicVisualizer: "waveform", } }) return instance @@ -225,6 +233,10 @@ func (c *Config) Save() error { viper.Set("auto_home_login", c.AutoHomeLogin) viper.Set("close_video_on_quit", c.CloseVideoOnQuit) viper.Set("terminal_video", c.TerminalVideo) + viper.Set("terminal_video_mode", c.TerminalVideoMode) + viper.Set("sixel_video_width", c.SixelVideoWidth) + viper.Set("poster_renderer", c.PosterRenderer) + viper.Set("window_music_visualizer", c.WindowMusicVisualizer) viper.Set("music_player", c.MusicPlayer) viper.Set("music_visualizer", c.MusicVisualizer) viper.Set("cache_dir", c.CacheDir) diff --git a/internal/config/video.go b/internal/config/video.go index 7ec8550..4f20a7c 100644 --- a/internal/config/video.go +++ b/internal/config/video.go @@ -24,3 +24,30 @@ func ParseTerminalVideo(value string) (VideoOutput, error) { return VideoWindow, fmt.Errorf("invalid terminal video renderer %q: choose auto, kitty, sixel, or tct", value) } } + +// ValidatePlayback checks settings before any renderer or player is started. +func (c *Config) ValidatePlayback() error { + if _, err := ParseTerminalVideo(string(c.TerminalVideo)); err != nil { + return err + } + for _, setting := range []struct { + name, value string + allowed []string + }{ + {name: "poster_renderer", value: c.PosterRenderer, allowed: []string{"auto", "kitty", "sixel", "halfcell"}}, + {name: "terminal_video_mode", value: c.TerminalVideoMode, allowed: []string{"suspend", "overlay"}}, + {name: "window_music_visualizer", value: c.WindowMusicVisualizer, allowed: []string{"cqt", "spectrum", "waves", "scope", "off"}}, + } { + valid := false + for _, choice := range setting.allowed { + valid = valid || setting.value == choice + } + if !valid { + return fmt.Errorf("invalid %s %q: choose %v", setting.name, setting.value, setting.allowed) + } + } + if c.SixelVideoWidth < 0 { + return fmt.Errorf("sixel_video_width must be zero (automatic) or a positive pixel width") + } + return nil +} diff --git a/internal/config/video_test.go b/internal/config/video_test.go index cb22913..4be2853 100644 --- a/internal/config/video_test.go +++ b/internal/config/video_test.go @@ -1,6 +1,11 @@ package config -import "testing" +import ( + "path/filepath" + "testing" + + "github.com/spf13/viper" +) func TestParseTerminalVideo(t *testing.T) { t.Parallel() @@ -18,3 +23,52 @@ func TestParseTerminalVideo(t *testing.T) { }) } } + +func TestPlaybackSettingsSurviveSaveAndReload(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + path := filepath.Join(t.TempDir(), "plexctl.yaml") + viper.SetConfigFile(path) + settings := Config{ + ConfigPath: path, TerminalVideo: VideoSixel, TerminalVideoMode: "overlay", + SixelVideoWidth: 480, PosterRenderer: "halfcell", WindowMusicVisualizer: "scope", + MusicPlayer: "window", MusicVisualizer: "mirror", + } + if err := settings.Save(); err != nil { + t.Fatal(err) + } + loaded := viper.New() + loaded.SetConfigFile(path) + if err := loaded.ReadInConfig(); err != nil { + t.Fatal(err) + } + var got Config + if err := loaded.Unmarshal(&got); err != nil { + t.Fatal(err) + } + if err := got.ValidatePlayback(); err != nil { + t.Fatal(err) + } + if got.TerminalVideo != VideoSixel || got.TerminalVideoMode != "overlay" || + got.SixelVideoWidth != 480 || got.PosterRenderer != "halfcell" || + got.WindowMusicVisualizer != "scope" || got.MusicPlayer != "window" || got.MusicVisualizer != "mirror" { + t.Fatal("saved playback preferences changed after reloading configuration") + } + for _, tt := range []struct { + name string + edit func(*Config) + }{ + {"negative pixel cap", func(c *Config) { c.SixelVideoWidth = -1 }}, + {"unknown poster protocol", func(c *Config) { c.PosterRenderer = "gpu" }}, + {"unknown video mode", func(c *Config) { c.TerminalVideoMode = "background" }}, + {"unsupported music graph", func(c *Config) { c.WindowMusicVisualizer = "invalid" }}, + } { + t.Run(tt.name, func(t *testing.T) { + invalid := got + tt.edit(&invalid) + if invalid.ValidatePlayback() == nil { + t.Fatal("invalid playback setting accepted") + } + }) + } +} diff --git a/internal/plex/media.go b/internal/plex/media.go index 5810cd0..eebeb55 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -2,7 +2,6 @@ package plex import ( "context" - "encoding/json" "fmt" "io" "log/slog" @@ -110,64 +109,7 @@ func ptr[T any](v T) *T { // GetChildren retrieves children of an item (e.g. seasons of a show, or episodes of a season) func GetChildren(ctx context.Context, ratingKey string) ([]components.Metadata, error) { - cfg := config.Get() - server := config.ServerFromContext(ctx) - if server.URL == "" { - return nil, fmt.Errorf("no active server") - } - - cm, err := cache.Get(cfg.CacheDir) - if err != nil { - return nil, err - } - - cacheKey := fmt.Sprintf("%s/children/%s", server.CacheNamespace(), ratingKey) - var body components.MediaContainerWithMetadata - if err := cm.Get(cacheKey, &body); err == nil { - return body.MediaContainer.Metadata, nil - } - - url := fmt.Sprintf("%s/library/metadata/%s/children", server.URL, ratingKey) - separator := "?" - if strings.Contains(url, "?") { - separator = "&" - } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(server.Token)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer func() { - if err := resp.Body.Close(); err != nil { - slog.Debug("Close children response", "error", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch children: %s", resp.Status) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if err := json.Unmarshal(data, &body); err != nil { - return nil, fmt.Errorf("failed to parse children: %w", err) - } - - if body.MediaContainer == nil { - return nil, fmt.Errorf("no media container in children response") - } - - _ = cm.Set(cacheKey, body, MediaCacheTTL) - return body.MediaContainer.Metadata, nil + return metadataListing(ctx, "/library/metadata/"+neturl.PathEscape(ratingKey)+"/children", nil) } // GetImage retrieves image data (thumb/poster) and caches it diff --git a/internal/plex/music.go b/internal/plex/music.go new file mode 100644 index 0000000..190ca5a --- /dev/null +++ b/internal/plex/music.go @@ -0,0 +1,207 @@ +package plex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/cache" + "github.com/ygelfand/plexctl/internal/config" +) + +// SectionIDOf returns the section carried by Plex's extensible metadata. +func SectionIDOf(meta *components.Metadata) string { + if meta == nil { + return "" + } + switch value := meta.AdditionalProperties["librarySectionID"].(type) { + case string: + return value + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + case json.Number: + return value.String() + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + default: + return "" + } +} + +// GetArtistAlbums includes soundtracks and compilations where the artist is a +// track artist, even when the album belongs to a different album artist. +func GetArtistAlbums(ctx context.Context, sectionID, artistKey string) ([]components.Metadata, error) { + return artistListing(ctx, sectionID, artistKey, "9") +} + +func GetArtistTracks(ctx context.Context, sectionID, artistKey string) ([]components.Metadata, error) { + return artistListing(ctx, sectionID, artistKey, "10") +} + +func artistListing(ctx context.Context, sectionID, artistKey, mediaType string) ([]components.Metadata, error) { + query := url.Values{"artist.id": {artistKey}, "type": {mediaType}} + return metadataListing(ctx, "/library/sections/"+url.PathEscape(sectionID)+"/all", query) +} + +// GetMusicChildren uses the same artist query for browsing and playback. +func GetMusicChildren(ctx context.Context, meta *components.Metadata) ([]components.Metadata, error) { + if meta == nil || meta.RatingKey == nil { + return nil, fmt.Errorf("music item has no rating key") + } + if meta.Type == "artist" { + if section := SectionIDOf(meta); section != "" { + return GetArtistAlbums(ctx, section, *meta.RatingKey) + } + } + return GetChildren(ctx, *meta.RatingKey) +} + +// CollectTracks retains Plex's order and resolves every playable track. A +// visited set handles duplicate/cyclic containers without dropping distinct +// recordings of the same song. Request failures abort rather than silently +// playing an incomplete album. +func CollectTracks(ctx context.Context, meta *components.Metadata) ([]components.Metadata, error) { + tracks := []components.Metadata{} + visited := make(map[string]bool) + var walk func(*components.Metadata) error + walk = func(item *components.Metadata) error { + if err := ctx.Err(); err != nil { + return err + } + if item == nil { + return nil + } + key := "" + if item.RatingKey != nil { + key = *item.RatingKey + } + if key != "" { + if visited[key] { + return nil + } + visited[key] = true + } + if item.Type == "track" { + if PlayablePart(item) == nil && key != "" { + full, err := GetMetadata(ctx, key, false) + if err != nil { + return err + } + item = full + } + if PlayablePart(item) != nil { + tracks = append(tracks, *item) + } + return nil + } + if key == "" || (item.Type != "artist" && item.Type != "album") { + return nil + } + var children []components.Metadata + var err error + if section := SectionIDOf(item); item.Type == "artist" && section != "" { + children, err = GetArtistTracks(ctx, section, key) + } else { + children, err = GetChildren(ctx, key) + } + if err != nil { + return err + } + for i := range children { + if err := walk(&children[i]); err != nil { + return err + } + } + return nil + } + if err := walk(meta); err != nil { + return nil, err + } + if len(tracks) == 0 { + return nil, fmt.Errorf("no playable tracks found") + } + return tracks, nil +} + +// metadataListing follows Plex pagination and namespaces the cache by server +// and credentials. Tokens travel in headers, keeping HTTP errors safe to show. +func metadataListing(ctx context.Context, path string, query url.Values) ([]components.Metadata, error) { + server := config.ServerFromContext(ctx) + if server.URL == "" { + return nil, fmt.Errorf("no active server") + } + cm, err := cache.Get(config.Get().CacheDir) + if err != nil { + return nil, err + } + cacheKey := server.CacheNamespace() + "/listing/" + path + "?" + query.Encode() + items := []components.Metadata{} + if err := cm.Get(cacheKey, &items); err == nil { + return items, nil + } + const pageSize = 200 + previousPage := "" + for start := 0; ; { + if err := ctx.Err(); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+path+"?"+query.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Plex-Token", server.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Plex-Container-Start", strconv.Itoa(start)) + req.Header.Set("X-Plex-Container-Size", strconv.Itoa(pageSize)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + var body components.MediaContainerWithMetadata + decodeErr := json.NewDecoder(resp.Body).Decode(&body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch music listing: %s", resp.Status) + } + if decodeErr != nil { + return nil, fmt.Errorf("decode music listing: %w", decodeErr) + } + if body.MediaContainer == nil { + return nil, fmt.Errorf("no media container in listing") + } + page := body.MediaContainer + if len(page.Metadata) == 0 { + if page.TotalSize != nil && int64(start) < *page.TotalSize { + return nil, fmt.Errorf("server returned an incomplete music listing") + } + break + } + // Detect servers that ignore the requested offset, avoiding an + // unbounded queue while still accepting an unpaginated response. + encoded, err := json.Marshal(page.Metadata) + if err != nil { + return nil, err + } + if string(encoded) == previousPage { + return nil, fmt.Errorf("server did not advance music listing pagination") + } + previousPage = string(encoded) + items = append(items, page.Metadata...) + start += len(page.Metadata) + if page.TotalSize != nil { + if int64(start) >= *page.TotalSize { + break + } + } else if len(page.Metadata) < pageSize { + break + } + } + _ = cm.Set(cacheKey, items, MediaCacheTTL) + return items, nil +} diff --git a/internal/plex/music_test.go b/internal/plex/music_test.go new file mode 100644 index 0000000..3bd038b --- /dev/null +++ b/internal/plex/music_test.go @@ -0,0 +1,108 @@ +package plex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/config" +) + +func TestArtistListingsAndQueueIncludeCompilations(t *testing.T) { + cfg := config.Get() + previous := cfg.CacheDir + cfg.CacheDir = t.TempDir() + t.Cleanup(func() { cfg.CacheDir = previous }) + artist := components.Metadata{RatingKey: ptr("artist"), Type: "artist", + AdditionalProperties: map[string]any{"librarySectionID": float64(7)}} + track := func(key string) components.Metadata { + return components.Metadata{RatingKey: ptr(key), Type: "track", Title: key, + Media: []components.Media{{Part: []components.Part{{Key: "/" + key + ".flac"}}}}} + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "friend & token" || r.URL.Query().Has("X-Plex-Token") { + t.Error("listing did not keep the originating credential in a header") + } + w.Header().Set("Content-Type", "application/json") + items := []components.Metadata{} + total := 3 + switch r.URL.Path { + case "/library/sections/7/all": + if r.URL.Query().Get("artist.id") != "artist" { + t.Error("artist filter missing") + } + switch r.URL.Query().Get("type") { + case "9": + items = []components.Metadata{{Title: "Soundtrack", Type: "album"}} + total = 1 + case "10": + switch r.Header.Get("X-Plex-Container-Start") { + case "0": + items = []components.Metadata{track("own"), {RatingKey: ptr("compilation"), Type: "track"}} + case "2": + items = []components.Metadata{track("guest")} + default: + t.Error("wrong page offset") + } + default: + t.Error("wrong media type") + } + case "/library/metadata/compilation": + items = []components.Metadata{track("compilation")} + default: + t.Errorf("unexpected children-only lookup: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": map[string]any{ + "Metadata": items, "totalSize": total, + }}) + })) + defer server.Close() + ctx := (config.ServerTarget{ID: "compilations", URL: server.URL, Token: "friend & token"}).Context(t.Context()) + albums, err := GetMusicChildren(ctx, &artist) + if err != nil || len(albums) != 1 || albums[0].Title != "Soundtrack" { + t.Fatalf("compilation albums: %v, %v", albums, err) + } + tracks, err := CollectTracks(ctx, &artist) + if err != nil { + t.Fatal(err) + } + keys := []string{} + for _, item := range tracks { + keys = append(keys, *item.RatingKey) + if PlayablePart(&item) == nil { + t.Fatal("sparse metadata was not resolved") + } + } + if !reflect.DeepEqual(keys, []string{"own", "compilation", "guest"}) { + t.Fatalf("incomplete or reordered queue: %v", keys) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + if _, err := CollectTracks(canceled, &artist); err == nil { + t.Fatal("canceled queue lookup succeeded") + } +} + +func TestCollectTracksRejectsPartialQueues(t *testing.T) { + cfg := config.Get() + previous := cfg.CacheDir + cfg.CacheDir = t.TempDir() + t.Cleanup(func() { cfg.CacheDir = previous }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/library/metadata/album/children" { + _, _ = w.Write([]byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"missing","type":"track"}]}}`)) + return + } + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + ctx := (config.ServerTarget{ID: "partial-queue", URL: server.URL, Token: "test"}).Context(t.Context()) + _, err := CollectTracks(ctx, &components.Metadata{RatingKey: ptr("album"), Type: "album"}) + if err == nil { + t.Fatal("failed track lookup was silently skipped") + } +} diff --git a/internal/termgraphics/overlay.go b/internal/termgraphics/overlay.go new file mode 100644 index 0000000..14a9623 --- /dev/null +++ b/internal/termgraphics/overlay.go @@ -0,0 +1,203 @@ +package termgraphics + +import ( + "bytes" + "fmt" + "image" + "io" + "math/rand/v2" + "strconv" + "strings" + "sync" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// OverlayWriter confines mpv's cursor and text to a TUI content rectangle. It +// suppresses screen switching, clearing, input modes, and cursor visibility; +// those belong to Bubble Tea. Only complete escape sequences reach the output. +type OverlayWriter struct { + mu sync.Mutex + output io.Writer + bounds func() image.Rectangle + graphics Writer + buffer bytes.Buffer + pending []byte + x, y int + foreground string + background string + hidden, closed bool +} + +func NewOverlayWriter(output io.Writer, bounds func() image.Rectangle, kitty bool) *OverlayWriter { + w := &OverlayWriter{output: output, bounds: bounds} + w.graphics.Output = &w.buffer + w.graphics.Direct = !InTmux() + if kitty { + w.graphics.ImageID = (1 << 30) + 2*rand.IntN(1<<19) + } + return w +} + +func (w *OverlayWriter) SetVisible(visible bool) bool { + w.mu.Lock() + defer w.mu.Unlock() + if w.hidden == !visible || w.closed { + return false + } + w.hidden = !visible + if w.hidden && w.graphics.ImageID != 0 { + _, _ = io.WriteString(w.output, Wrap(w.graphics.DeleteImages())) + } + return true +} + +func (w *OverlayWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return len(p), nil + } + rect := w.bounds() + w.pending = append(w.pending, p...) + var translated strings.Builder + fmt.Fprintf(&translated, "\x1b[%d;%dH%s%s", rect.Min.Y+w.y+1, rect.Min.X+w.x+1, w.foreground, w.background) + prefixLen := translated.Len() + for len(w.pending) > 0 { + // DecodeSequence treats an ESC at the end of a string as cancellation. + // Wait for ST ourselves so a write split between ESC and backslash + // cannot truncate an APC/DCS graphics packet. + graphics := bytes.HasPrefix(w.pending, []byte("\x1b_")) || bytes.HasPrefix(w.pending, []byte("\x1bP")) + if graphics && !bytes.Contains(w.pending, []byte("\x1b\\")) { + if len(w.pending) > 32<<20 { + return 0, fmt.Errorf("overlay graphics packet exceeds 32 MiB") + } + break + } + if w.pending[0] >= 0x80 && !utf8.FullRune(w.pending) { + break + } + sequence, width, n, state := ansi.DecodeSequence(w.pending, 0, nil) + if state != 0 || n == 0 { + if len(w.pending) > 32<<20 { + return 0, fmt.Errorf("overlay graphics packet exceeds 32 MiB") + } + break + } + w.pending = w.pending[n:] + s := string(sequence) + switch { + case strings.HasPrefix(s, "\x1b["): + command := s[len(s)-1] + args := strings.TrimSuffix(strings.TrimPrefix(s, "\x1b["), string(command)) + switch command { + case 'H', 'f': + row, col, _ := strings.Cut(args, ";") + y, _ := strconv.Atoi(row) + x, _ := strconv.Atoi(col) + w.x, w.y = max(0, x-1), max(0, y-1) + case 'A', 'B', 'C', 'D': + n, _ := strconv.Atoi(args) + n = max(1, n) + switch command { + case 'A': + w.y = max(0, w.y-n) + case 'B': + w.y += n + case 'C': + w.x += n + case 'D': + w.x = max(0, w.x-n) + } + case 'm': + translated.WriteString(s) + w.colors(args) + continue + default: + continue + } + fmt.Fprintf(&translated, "\x1b[%d;%dH", rect.Min.Y+w.y+1, rect.Min.X+w.x+1) + case strings.HasPrefix(s, "\x1b_G"), strings.HasPrefix(s, "\x1bP"): + if w.x < rect.Dx() && w.y < rect.Dy() { + translated.WriteString(s) + } + case s == "\n": + w.y++ + w.x = 0 + fmt.Fprintf(&translated, "\x1b[%d;%dH", rect.Min.Y+w.y+1, rect.Min.X+1) + case s == "\r": + w.x = 0 + fmt.Fprintf(&translated, "\x1b[%d;%dH", rect.Min.Y+w.y+1, rect.Min.X+1) + case width > 0: + if w.x+width <= rect.Dx() && w.y < rect.Dy() { + translated.WriteString(s) + } + w.x += width + } + } + if translated.Len() == prefixLen { + return len(p), nil + } + w.buffer.Reset() + if _, err := w.graphics.Write([]byte(translated.String())); err != nil { + return 0, err + } + if !w.hidden && rect.Dx() > 0 && rect.Dy() > 0 { + frame := "\x1b[?2026h\x1b7" + w.buffer.String() + "\x1b8\x1b[?2026l" + if _, err := io.WriteString(w.output, frame); err != nil { + return 0, err + } + } + return len(p), nil +} + +// TCT writes foreground and background separately; both must survive stdout +// chunk boundaries because saving/restoring the TUI cursor also restores SGR. +func (w *OverlayWriter) colors(args string) { + fields := strings.Split(args, ";") + for i := 0; i < len(fields); i++ { + switch fields[i] { + case "", "0": + w.foreground, w.background = "", "" + case "39": + w.foreground = "\x1b[39m" + case "49": + w.background = "\x1b[49m" + case "38", "48": + n := 0 + if i+1 < len(fields) { + switch fields[i+1] { + case "2": + n = 4 + case "5": + n = 2 + } + } + if n == 0 || i+n >= len(fields) { + continue + } + color := "\x1b[" + strings.Join(fields[i:i+n+1], ";") + "m" + if fields[i] == "38" { + w.foreground = color + } else { + w.background = color + } + i += n + } + } +} + +func (w *OverlayWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true + if w.graphics.ImageID != 0 { + _, err := io.WriteString(w.output, Wrap(w.graphics.DeleteImages())) + return err + } + return nil +} diff --git a/internal/termgraphics/overlay_test.go b/internal/termgraphics/overlay_test.go new file mode 100644 index 0000000..e2fb2f1 --- /dev/null +++ b/internal/termgraphics/overlay_test.go @@ -0,0 +1,82 @@ +package termgraphics + +import ( + "bytes" + "fmt" + "image" + "strings" + "testing" +) + +func TestOverlayConfinesTextAndOwnsNoTerminalModes(t *testing.T) { + t.Setenv("TMUX", "") + for _, chunk := range []int{1, 7, 4096} { + t.Run(fmt.Sprintf("chunk-%d", chunk), func(t *testing.T) { + var output bytes.Buffer + w := NewOverlayWriter(&output, func() image.Rectangle { return image.Rect(28, 2, 32, 4) }, false) + input := "\x1b[?1049h\x1b[2J\x1b[?25l\x1b[H12345\n67890\nOUTSIDE\x1b[?1049l" + for start := 0; start < len(input); start += chunk { + part := []byte(input[start:min(start+chunk, len(input))]) + if n, err := w.Write(part); err != nil || n != len(part) { + t.Fatalf("chunk %d: write = %d, %v", chunk, n, err) + } + } + got := output.String() + for _, forbidden := range []string{"1049", "[2J", "?25l", "OUTSIDE"} { + if strings.Contains(got, forbidden) { + t.Fatalf("overlay leaked %q with chunk %d: %q", forbidden, chunk, got) + } + } + if !strings.Contains(got, "\x1b[3;29H") || strings.Contains(got, "12345") || strings.Contains(got, "67890") { + t.Fatalf("overlay coordinates or clipping incorrect: %q", got) + } + if !strings.Contains(got, "\x1b7") || !strings.Contains(got, "\x1b8") { + t.Fatal("overlay did not preserve the TUI cursor") + } + }) + } +} + +func TestOverlayRestoresBothPixelColorsAcrossWrites(t *testing.T) { + var output bytes.Buffer + w := NewOverlayWriter(&output, func() image.Rectangle { return image.Rect(2, 2, 6, 6) }, false) + fg, bg := "\x1b[38;2;10;20;30m", "\x1b[48;2;40;50;60m" + _, _ = w.Write([]byte(fg + bg)) + output.Reset() + _, _ = w.Write([]byte("▀")) + if !strings.Contains(output.String(), fg+bg+"▀") { + t.Fatalf("pixel lost color after the TUI cursor was restored: %q", output.String()) + } +} + +func TestOverlayBuffersGraphicsAndCleansUpOnlyItsImages(t *testing.T) { + t.Setenv("TMUX", "") + var output bytes.Buffer + w := NewOverlayWriter(&output, func() image.Rectangle { return image.Rect(10, 1, 30, 10) }, true) + packet := "\x1b_Ga=T,f=24,s=1,v=1;AAAA\x1b\\" + for _, b := range []byte(packet) { + if _, err := w.Write([]byte{b}); err != nil { + t.Fatal(err) + } + } + if strings.Count(output.String(), "\x1b_Ga=T") != 1 || !strings.Contains(output.String(), "AAAA\x1b\\") { + t.Fatalf("fragmented graphics were corrupted: %q; pending %q", output.String(), w.pending) + } + w.SetVisible(false) + output.Reset() + _, _ = w.Write([]byte(packet)) + if output.Len() != 0 { + t.Fatal("video painted over a TUI dialog") + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "d=A") || !strings.Contains(output.String(), "d=I") { + t.Fatal("cleanup did not target only video-owned images") + } + output.Reset() + _, _ = w.Write([]byte(packet)) + if output.Len() != 0 { + t.Fatal("closed overlay still writes to the terminal") + } +} diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go index 4899cc6..7fa3ab6 100644 --- a/internal/termgraphics/terminal.go +++ b/internal/termgraphics/terminal.go @@ -30,12 +30,24 @@ type Capabilities struct { KittySharedMemory bool Sixel bool Name string + CellWidth int + CellHeight int } // NativePosters requires Unicode placeholders, which basic Kitty graphics // support alone does not guarantee (notably on older Kitty and other emulators). func (c Capabilities) NativePosters() bool { - if !c.Kitty || !strings.HasPrefix(c.Name, "kitty(") { + if !c.Kitty { + return false + } + name := strings.ToLower(c.Name) + if strings.HasPrefix(name, "ghostty") { + return true + } + if name == "" && (strings.EqualFold(os.Getenv("TERM_PROGRAM"), "ghostty") || os.Getenv("TERM") == "xterm-ghostty") { + return true + } + if !strings.HasPrefix(name, "kitty(") { return false } version := strings.Split(strings.TrimSuffix(strings.TrimPrefix(c.Name, "kitty("), ")"), ".") @@ -174,6 +186,7 @@ func ZoomPane() (func(), error) { var attributes = regexp.MustCompile(`\x1b\[\?([0-9;]+)c`) var terminalName = regexp.MustCompile(`\x1bP>\|([^\x1b]+)\x1b\\`) +var cellSizeReply = regexp.MustCompile(`\x1b\[6;(\d+);(\d+)t`) func parseCapabilities(reply string) Capabilities { c := Capabilities{ @@ -192,6 +205,10 @@ func parseCapabilities(reply string) Capabilities { c.Responded = true } c.Responded = c.Responded || c.Kitty + if match := cellSizeReply.FindStringSubmatch(reply); match != nil { + c.CellHeight, _ = strconv.Atoi(match[1]) + c.CellWidth, _ = strconv.Atoi(match[2]) + } return c } @@ -258,7 +275,7 @@ func Probe(input *os.File, output io.Writer) Capabilities { }() shm, cleanup := sharedMemoryQuery() defer cleanup() - _, _ = io.WriteString(output, Wrap("\x1b_Ga=q,U=1,i=18901,s=1,v=1,f=24;AAAA\x1b\\"+shm+"\x1b[c\x1b[>q")) + _, _ = io.WriteString(output, Wrap("\x1b_Ga=q,U=1,i=18901,s=1,v=1,f=24;AAAA\x1b\\"+shm+"\x1b[c\x1b[>q\x1b[16t")) timer := time.NewTimer(350 * time.Millisecond) select { case <-done: diff --git a/internal/termgraphics/terminal_test.go b/internal/termgraphics/terminal_test.go index 9dc703d..092d2f0 100644 --- a/internal/termgraphics/terminal_test.go +++ b/internal/termgraphics/terminal_test.go @@ -51,6 +51,7 @@ func TestCapabilities(t *testing.T) { {"local kitty", "\x1b_Gi=18901;OK\x1b\\\x1b_Gi=18902;OK\x1b\\", Capabilities{Responded: true, Kitty: true, KittySharedMemory: true}}, {"remote kitty", "\x1b_Gi=18901;OK\x1b\\\x1b_Gi=18902;EBADF:Failed to read image\x1b\\", Capabilities{Responded: true, Kitty: true}}, {"sixel", "\x1b[?64;1;4;22c", Capabilities{Responded: true, Sixel: true}}, + {"cell size", "\x1b[6;20;10t\x1b[?64;4c", Capabilities{Responded: true, Sixel: true, CellWidth: 10, CellHeight: 20}}, {"not four", "\x1b[?64;14;24c", Capabilities{Responded: true}}, } { t.Run(tt.name, func(t *testing.T) { @@ -119,14 +120,21 @@ func TestKittyFramePlacement(t *testing.T) { } func TestNativePostersRequireUnicodePlaceholders(t *testing.T) { + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") for _, tt := range []struct { name string want bool }{ {"kitty(0.27.0)", false}, {"kitty(0.28.0)", true}, {"kitty(0.49.2)", true}, {"kitty(1.0.0)", true}, {"VTE(7600)", false}, {"", false}, + {"ghostty 1.2.0", true}, {"Ghostty(1.3.0)", true}, {"WezTerm 20240203", false}, } { if got := (Capabilities{Kitty: true, Name: tt.name}).NativePosters(); got != tt.want { t.Errorf("%s: got %v", tt.name, got) } } + t.Setenv("TERM_PROGRAM", "ghostty") + if !(Capabilities{Kitty: true}).NativePosters() || (Capabilities{}).NativePosters() { + t.Fatal("Ghostty environment fallback must still require a successful graphics probe") + } } diff --git a/internal/tui/artwork/artwork.go b/internal/tui/artwork/artwork.go index 195a9d5..de0434d 100644 --- a/internal/tui/artwork/artwork.go +++ b/internal/tui/artwork/artwork.go @@ -1,6 +1,3 @@ -// Package artwork renders native Kitty posters using Unicode placeholders. -// Placeholders remain ordinary cells to Bubble Tea and tmux, so scrolling, -// clipping, borders, and overlays work without an independent image layer. package artwork import ( @@ -16,82 +13,62 @@ import ( "sync" "github.com/charmbracelet/x/ansi/kitty" - gopixels "github.com/saran13raj/go-pixels" - "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/termgraphics" "golang.org/x/image/draw" ) -var posters = struct { +type kittyRenderer struct { sync.Mutex - enabled bool - dir string - nextID int - views map[string]string - files map[int]string - wanted map[int]bool - sent map[int]uint64 - clock uint64 -}{} + dir string + nextID int + views map[string]string + files map[int]string + wanted map[int]bool + sent map[int]uint64 + clock uint64 +} -// Open enables native posters only after a successful Kitty protocol probe. -// Image payloads live in a temporary directory, rather than accumulating in RAM -// or persisting terminal-specific image IDs in the old text-poster cache. -func Open(output *os.File, enabled bool) (*Output, error) { - posters.Lock() - defer posters.Unlock() - posters.enabled = enabled - posters.views = make(map[string]string) - posters.files = make(map[int]string) - posters.wanted = make(map[int]bool) - posters.sent = make(map[int]uint64) - posters.nextID = 1 + rand.IntN(1<<22) - if enabled { - dir, err := os.MkdirTemp("", "plexctl-posters-") - if err != nil { - posters.enabled = false - return nil, err - } - posters.dir = dir +func newKittyRenderer() (Renderer, error) { + dir, err := os.MkdirTemp("", "plexctl-posters-") + if err != nil { + return nil, err } - return &Output{file: output}, nil + return &kittyRenderer{ + dir: dir, views: make(map[string]string), files: make(map[int]string), + wanted: make(map[int]bool), sent: make(map[int]uint64), nextID: 1 + rand.IntN(1<<22), + }, nil } -func key(ratingKey string, width int) string { +func (*kittyRenderer) Name() string { return "kitty" } + +func key(ratingKey string, width, height int) string { // Callers include the server and credential namespace in the artwork key. - return fmt.Sprintf("%s/%d", ratingKey, width) + return fmt.Sprintf("%s/%d/%d", ratingKey, width, height) } -func Cached(ratingKey string, width int) (string, bool) { - posters.Lock() - defer posters.Unlock() - if posters.enabled { - view, ok := posters.views[key(ratingKey, width)] - return view, ok - } - return plex.GetCachedPoster(ratingKey, width) +func (r *kittyRenderer) Cached(ratingKey string, width, height int) (string, bool) { + r.Lock() + defer r.Unlock() + view, ok := r.views[key(ratingKey, width, height)] + return view, ok } -func Render(ratingKey string, img image.Image, width, height int) (string, error) { - posters.Lock() - enabled, dir := posters.enabled, posters.dir - id := posters.nextID - posters.nextID++ - posters.Unlock() - if !enabled { - view, err := gopixels.FromImageStream(img, width, 0, "halfcell", true) - if err == nil && ratingKey != "" { - plex.SetCachedPoster(ratingKey, width, view) - } - return view, err +func (r *kittyRenderer) Render(ratingKey string, img image.Image, width, height int) (string, error) { + r.Lock() + defer r.Unlock() + if cached, ok := r.views[key(ratingKey, width, height)]; ok { + return cached, nil } + dir, id := r.dir, r.nextID + r.nextID++ + cacheKey := key(ratingKey, width, height) if width <= 0 || width > 255 { return "", fmt.Errorf("invalid poster width: %d", width) } - if height <= 0 { - height = max(1, (width*img.Bounds().Dy()/img.Bounds().Dx()+1)/2) + if img == nil || img.Bounds().Empty() { + return "", fmt.Errorf("empty poster image") } - height = min(height, 255) + cols, rows := fitImage(img.Bounds(), width, height) // Retain hundreds of pixels of detail, including on HiDPI displays, while // bounding transport and terminal texture memory for very large originals. if longest := max(img.Bounds().Dx(), img.Bounds().Dy()); longest > 768 { @@ -108,7 +85,7 @@ func Render(ratingKey string, img image.Image, width, height int) (string, error err = kitty.EncodeGraphics(f, img, &kitty.Options{ Action: kitty.TransmitAndPut, Quite: 2, ID: id, Format: kitty.PNG, Transmission: kitty.Direct, Chunk: true, ChunkFormatter: termgraphics.Wrap, - Columns: width, Rows: height, VirtualPlacement: true, + Columns: cols, Rows: rows, VirtualPlacement: true, }) closeErr := f.Close() if err != nil { @@ -117,11 +94,9 @@ func Render(ratingKey string, img image.Image, width, height int) (string, error if closeErr != nil { return "", closeErr } - view := placeholders(id, width, height) - posters.Lock() - posters.files[id] = path - posters.views[key(ratingKey, width)] = view - posters.Unlock() + view := padImage(placeholders(id, cols, rows), width, height) + r.files[id] = path + r.views[cacheKey] = view return view, nil } @@ -148,88 +123,77 @@ var imageColor = regexp.MustCompile(`\x1b\[38;2;(\d+);(\d+);(\d+)m`) // Frame records images in the final, clipped/composited view. The output writer // sends their payloads before the next screen update, on the renderer's own // goroutine. Background image fetches never write directly to the terminal. -func Frame(view string) string { - posters.Lock() - defer posters.Unlock() - if !posters.enabled { - return view - } +func (r *kittyRenderer) Frame(view string) string { + r.Lock() + defer r.Unlock() wanted := make(map[int]bool) for _, m := range imageColor.FindAllStringSubmatch(view, -1) { - r, _ := strconv.Atoi(m[1]) + red, _ := strconv.Atoi(m[1]) g, _ := strconv.Atoi(m[2]) b, _ := strconv.Atoi(m[3]) - id := r<<16 | g<<8 | b - if _, ok := posters.files[id]; ok { + id := red<<16 | g<<8 | b + if _, ok := r.files[id]; ok { wanted[id] = true } } - posters.wanted = wanted + r.wanted = wanted return view } -type Output struct{ file *os.File } - -func (o *Output) Fd() uintptr { return o.file.Fd() } - -func (o *Output) Read(p []byte) (int, error) { return o.file.Read(p) } - -// TerminalFile keeps mpv attached to a real TTY when Bubble Tea lends it output. -func (o *Output) TerminalFile() *os.File { return o.file } - -func (o *Output) Write(p []byte) (int, error) { - posters.Lock() - defer posters.Unlock() +func (r *kittyRenderer) Write(out io.Writer, p []byte) (int, error) { + r.Lock() + defer r.Unlock() if strings.Contains(string(p), "\x1b[?1049l") { // An external player may clear graphics while Bubble Tea is suspended. - clear(posters.sent) - return o.file.Write(p) + clear(r.sent) + return out.Write(p) } - for id := range posters.wanted { - posters.clock++ - if _, ok := posters.sent[id]; !ok { - data, err := os.ReadFile(posters.files[id]) + for id := range r.wanted { + r.clock++ + if _, ok := r.sent[id]; !ok { + data, err := os.ReadFile(r.files[id]) if err != nil { return 0, err } - if _, err := o.file.Write(data); err != nil { + if _, err := out.Write(data); err != nil { return 0, err } } - posters.sent[id] = posters.clock + r.sent[id] = r.clock } // Bound terminal texture memory; inactive posters can be uploaded again. - for len(posters.sent) > max(64, len(posters.wanted)) { + for len(r.sent) > max(64, len(r.wanted)) { oldest, age := 0, ^uint64(0) - for id, used := range posters.sent { - if !posters.wanted[id] && used < age { + for id, used := range r.sent { + if !r.wanted[id] && used < age { oldest, age = id, used } } if oldest == 0 { break } - _, _ = io.WriteString(o.file, deleteImage(oldest)) - delete(posters.sent, oldest) + _, _ = io.WriteString(out, deleteImage(oldest)) + delete(r.sent, oldest) } - return o.file.Write(p) + return out.Write(p) } func deleteImage(id int) string { return termgraphics.Wrap(fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)) } -func (o *Output) Close() error { - posters.Lock() - defer posters.Unlock() - for id := range posters.files { - _, _ = io.WriteString(o.file, deleteImage(id)) - } - posters.enabled = false - clear(posters.wanted) - clear(posters.files) - if posters.dir != "" { - return os.RemoveAll(posters.dir) +func (r *kittyRenderer) Close(out io.Writer) error { + r.Lock() + defer r.Unlock() + for id := range r.files { + _, _ = io.WriteString(out, deleteImage(id)) + } + clear(r.wanted) + clear(r.files) + if r.dir != "" { + err := os.RemoveAll(r.dir) + r.dir = "" + return err } return nil } diff --git a/internal/tui/artwork/artwork_test.go b/internal/tui/artwork/artwork_test.go index 1e70a4c..17de5f2 100644 --- a/internal/tui/artwork/artwork_test.go +++ b/internal/tui/artwork/artwork_test.go @@ -45,7 +45,7 @@ func TestNativePosterLifecycle(t *testing.T) { t.Fatal("incorrect placeholder dimensions") } frame := Frame(lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Render(view)) - if len(posters.wanted) != 1 { + if len(active.renderer.(*kittyRenderer).wanted) != 1 { t.Fatal("image lost when composing frame") } if _, err := w.Write([]byte(frame)); err != nil { @@ -95,7 +95,7 @@ func TestNativePosterLifecycle(t *testing.T) { if size()-before <= int64(len(frame)) { t.Fatal("poster not reuploaded after returning from playback") } - dir := posters.dir + dir := active.renderer.(*kittyRenderer).dir if err := w.Close(); err != nil { t.Fatal(err) } diff --git a/internal/tui/artwork/renderer.go b/internal/tui/artwork/renderer.go new file mode 100644 index 0000000..97b9a99 --- /dev/null +++ b/internal/tui/artwork/renderer.go @@ -0,0 +1,218 @@ +// Package artwork composes terminal posters with Bubble Tea's text layout. +package artwork + +import ( + "fmt" + "image" + "io" + "math" + "os" + "sync" + + "github.com/charmbracelet/lipgloss" + gopixels "github.com/saran13raj/go-pixels" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +// Renderer owns protocol state. Frame sees the composited, clipped view; Write +// runs on Bubble Tea's renderer goroutine, keeping image and text writes atomic. +type Renderer interface { + Name() string + Cached(key string, width, height int) (string, bool) + Render(key string, img image.Image, width, height int) (string, error) + Frame(view string) string + Write(out io.Writer, p []byte) (int, error) + Close(out io.Writer) error +} + +var renderers = []struct { + name string + supported func(termgraphics.Capabilities) bool + build func() (Renderer, error) +}{ + {"kitty", func(c termgraphics.Capabilities) bool { return c.NativePosters() }, newKittyRenderer}, + {"sixel", func(c termgraphics.Capabilities) bool { return c.Sixel }, newSixelRenderer}, + {"halfcell", func(termgraphics.Capabilities) bool { return true }, func() (Renderer, error) { return halfcellRenderer{}, nil }}, +} + +var active = struct { + sync.RWMutex + renderer Renderer + output *Output + caps termgraphics.Capabilities +}{renderer: halfcellRenderer{}} + +// Open preserves the explicit Kitty/halfcell entry point for embedders. +func Open(file *os.File, kitty bool) (*Output, error) { + mode := "halfcell" + if kitty { + mode = "kitty" + } + return OpenRenderer(file, mode, termgraphics.Current()) +} + +func OpenRenderer(file *os.File, mode string, caps termgraphics.Capabilities) (*Output, error) { + active.Lock() + defer active.Unlock() + renderer, err := chooseRenderer(mode, caps) + if err != nil { + return nil, err + } + if active.output != nil { + _ = active.renderer.Close(active.output.file) + } + active.renderer, active.caps = renderer, caps + active.output = &Output{file: file} + return active.output, nil +} + +func chooseRenderer(mode string, caps termgraphics.Capabilities) (Renderer, error) { + for _, factory := range renderers { + if mode == factory.name || ((mode == "auto" || mode == "") && factory.supported(caps)) { + return factory.build() + } + } + return nil, fmt.Errorf("invalid poster renderer %q: choose auto, kitty, sixel, or halfcell", mode) +} + +// Select applies a settings change while existing fetches finish under the old +// renderer's read lock. UI consumers reload their poster strings after changing. +func Select(mode string) error { + active.Lock() + defer active.Unlock() + renderer, err := chooseRenderer(mode, active.caps) + if err != nil { + return err + } + if renderer.Name() == active.renderer.Name() { + return renderer.Close(io.Discard) + } + if active.output != nil { + if err := active.renderer.Close(active.output.file); err != nil { + _ = renderer.Close(io.Discard) + return err + } + } + active.renderer = renderer + return nil +} + +func Name() string { + active.RLock() + defer active.RUnlock() + return active.renderer.Name() +} + +func Cached(key string, width int, heights ...int) (string, bool) { + height := 0 + if len(heights) > 0 { + height = heights[0] + } + active.RLock() + defer active.RUnlock() + return active.renderer.Cached(key, width, height) +} + +func Render(key string, img image.Image, width, height int) (string, error) { + if img == nil || img.Bounds().Empty() || width < 1 { + return "", fmt.Errorf("poster requires a nonempty image and positive width") + } + active.RLock() + defer active.RUnlock() + return active.renderer.Render(key, img, width, height) +} + +func Frame(view string) string { + active.RLock() + defer active.RUnlock() + return active.renderer.Frame(view) +} + +type Output struct { + file *os.File + mu sync.Mutex +} + +func (o *Output) Fd() uintptr { return o.file.Fd() } +func (o *Output) Read(p []byte) (int, error) { return o.file.Read(p) } +func (o *Output) TerminalFile() *os.File { return o.file } +func (o *Output) Write(p []byte) (int, error) { + active.RLock() + defer active.RUnlock() + o.mu.Lock() + defer o.mu.Unlock() + return active.renderer.Write(o.file, p) +} +func (o *Output) Close() error { + active.Lock() + defer active.Unlock() + if active.output != o { + return nil + } + o.mu.Lock() + defer o.mu.Unlock() + err := active.renderer.Close(o.file) + active.renderer, active.output = halfcellRenderer{}, nil + return err +} + +// VideoOutput serializes complete video packets with the UI renderer without +// treating video data as a new poster frame. The wrapper owns cursor saving. +func VideoOutput() io.Writer { return videoOutput{} } + +type videoOutput struct{} + +func (videoOutput) Write(p []byte) (int, error) { + active.RLock() + defer active.RUnlock() + if active.output == nil { + return 0, fmt.Errorf("terminal video overlay requires a running TUI") + } + active.output.mu.Lock() + defer active.output.mu.Unlock() + return active.output.file.Write(p) +} + +type halfcellRenderer struct{} + +func (halfcellRenderer) Name() string { return "halfcell" } +func (halfcellRenderer) Cached(id string, width, height int) (string, bool) { + return plex.GetCachedPoster("halfcell/v2/"+key(id, width, height), width) +} +func (r halfcellRenderer) Render(id string, img image.Image, width, height int) (string, error) { + cols, rows := fitImageWithCells(img.Bounds(), width, height, 1, 2) + view, err := gopixels.FromImageStream(img, cols, rows*2, "halfcell", true) + if err != nil { + return "", err + } + view = padImage(view, width, height) + plex.SetCachedPoster("halfcell/v2/"+key(id, width, height), width, view) + return view, nil +} +func (halfcellRenderer) Frame(view string) string { return view } +func (halfcellRenderer) Write(out io.Writer, p []byte) (int, error) { return out.Write(p) } +func (halfcellRenderer) Close(io.Writer) error { return nil } + +func fitImage(bounds image.Rectangle, width, height int) (int, int) { + caps := termgraphics.Current() + cw, ch := caps.CellWidth, caps.CellHeight + if cw <= 0 || ch <= 0 { + cw, ch = 8, 16 + } + return fitImageWithCells(bounds, width, height, cw, ch) +} + +func fitImageWithCells(bounds image.Rectangle, width, height, cw, ch int) (int, int) { + cols := max(1, min(width, 255)) + rows := max(1, int(math.Round(float64(bounds.Dy()*cols*cw)/float64(bounds.Dx()*ch)))) + if height > 0 && rows > height { + rows = height + cols = max(1, min(cols, int(math.Round(float64(bounds.Dx()*rows*ch)/float64(bounds.Dy()*cw))))) + } + return cols, min(rows, 255) +} + +func padImage(view string, width, height int) string { + return lipgloss.NewStyle().Width(width).Height(height).Align(lipgloss.Center, lipgloss.Center).Render(view) +} diff --git a/internal/tui/artwork/sixel.go b/internal/tui/artwork/sixel.go new file mode 100644 index 0000000..fb003c0 --- /dev/null +++ b/internal/tui/artwork/sixel.go @@ -0,0 +1,320 @@ +package artwork + +import ( + "bytes" + "fmt" + "image" + "image/png" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" + "github.com/charmbracelet/x/ansi/sixel" + "github.com/ygelfand/plexctl/internal/termgraphics" + "golang.org/x/image/draw" +) + +// Sixel has no movable text placements. Reuse our placeholder cells internally +// for clipping/compositing, replace them with spaces at the output boundary, +// then draw only visible pixel rectangles after the text frame. +type sixelRenderer struct { + sync.Mutex + dir string + nextID int + views map[string]string + files map[int]string + frame string + last string + placements []imagePlacement + encoded map[imagePlacement]string + cellW int + cellH int +} + +type imagePlacement struct { + ID int + X, Y int + Source image.Rectangle // cells within the original image +} + +func newSixelRenderer() (Renderer, error) { + dir, err := os.MkdirTemp("", "plexctl-sixel-") + if err != nil { + return nil, err + } + caps := termgraphics.Current() + cw, ch := caps.CellWidth, caps.CellHeight + if cw <= 0 || ch <= 0 { + cw, ch = 8, 16 + } + return &sixelRenderer{dir: dir, nextID: 1, cellW: cw, cellH: ch, + views: make(map[string]string), files: make(map[int]string), encoded: make(map[imagePlacement]string)}, nil +} + +func (*sixelRenderer) Name() string { return "sixel" } + +func (r *sixelRenderer) Cached(id string, width, height int) (string, bool) { + r.Lock() + defer r.Unlock() + view, ok := r.views[key(id, width, height)] + return view, ok +} + +func (r *sixelRenderer) Render(id string, img image.Image, width, height int) (string, error) { + r.Lock() + defer r.Unlock() + cacheKey := key(id, width, height) + if view, ok := r.views[cacheKey]; ok { + return view, nil + } + cols, rows := fitImageWithCells(img.Bounds(), width, height, r.cellW, r.cellH) + scaled := image.NewRGBA(image.Rect(0, 0, cols*r.cellW, rows*r.cellH)) + draw.ApproxBiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), draw.Src, nil) + imageID := r.nextID + r.nextID++ + path := filepath.Join(r.dir, strconv.Itoa(imageID)) + f, err := os.Create(path) + if err != nil { + return "", err + } + encodeErr := png.Encode(f, scaled) + closeErr := f.Close() + if encodeErr != nil { + return "", encodeErr + } + if closeErr != nil { + return "", closeErr + } + view := padImage(placeholders(imageID, cols, rows), width, height) + r.files[imageID], r.views[cacheKey] = path, view + return view, nil +} + +func (r *sixelRenderer) Frame(view string) string { + r.Lock() + defer r.Unlock() + r.frame = view + r.placements = scanImages(view, r.files) + return view +} + +var diacriticIndex = func() map[rune]int { + result := make(map[rune]int) + for i := range 256 { + result[kitty.Diacritic(i)] = i + } + return result +}() + +func scanImages(view string, files map[int]string) []imagePlacement { + placements := []imagePlacement{} + x, y, foreground := 0, 0, 0 + var state byte + for len(view) > 0 { + sequence, width, n, next := ansi.DecodeSequence(view, state, nil) + if n == 0 { + break + } + view, state = view[n:], next + if sequence == "\n" { + x, y = 0, y+1 + continue + } + if sequence == "\r" { + x = 0 + continue + } + if strings.HasPrefix(sequence, "\x1b[") && strings.HasSuffix(sequence, "m") { + foreground = sgrForeground(sequence, foreground) + } + runes := []rune(sequence) + if len(runes) >= 3 && runes[0] == kitty.Placeholder && files[foreground] != "" { + row, rowOK := diacriticIndex[runes[1]] + col, colOK := diacriticIndex[runes[2]] + if rowOK && colOK { + cell := imagePlacement{ID: foreground, X: x, Y: y, Source: image.Rect(col, row, col+1, row+1)} + if len(placements) > 0 { + last := &placements[len(placements)-1] + if last.ID == cell.ID && last.Y == y && last.X+last.Source.Dx() == x && + last.Source.Min.Y == row && last.Source.Max.X == col { + last.Source.Max.X++ + x += width + continue + } + } + placements = append(placements, cell) + } + } + x += width + } + // Merge matching horizontal runs into rectangles, including separate copies + // of one image and pieces exposed beside or below an overlay. + merged := []imagePlacement{} + for _, run := range placements { + joined := false + for i := len(merged) - 1; i >= 0; i-- { + previous := &merged[i] + if previous.ID == run.ID && previous.X == run.X && previous.Y+previous.Source.Dy() == run.Y && + previous.Source.Min.X == run.Source.Min.X && previous.Source.Max.X == run.Source.Max.X && + previous.Source.Max.Y == run.Source.Min.Y { + previous.Source.Max.Y++ + joined = true + break + } + } + if !joined { + merged = append(merged, run) + } + } + return merged +} + +func sgrForeground(sequence string, previous int) int { + values := strings.Split(strings.TrimSuffix(strings.TrimPrefix(sequence, "\x1b["), "m"), ";") + for i := 0; i < len(values); i++ { + switch values[i] { + case "", "0", "39": + previous = 0 + case "38": + if i+4 < len(values) && values[i+1] == "2" { + r, _ := strconv.Atoi(values[i+2]) + g, _ := strconv.Atoi(values[i+3]) + b, _ := strconv.Atoi(values[i+4]) + previous = r<<16 | g<<8 | b + i += 4 + } + case "48", "58": + if i+1 < len(values) { + switch values[i+1] { + case "2": + i += 4 + case "5": + i += 2 + } + } + } + } + return previous +} + +func withoutPlaceholders(text string) string { + var result strings.Builder + var state byte + for len(text) > 0 { + sequence, _, n, next := ansi.DecodeSequence(text, state, nil) + if n == 0 { + break + } + if strings.HasPrefix(sequence, string(kitty.Placeholder)) { + result.WriteByte(' ') + } else { + result.WriteString(sequence) + } + text, state = text[n:], next + } + return result.String() +} + +func (r *sixelRenderer) Write(out io.Writer, p []byte) (int, error) { + r.Lock() + defer r.Unlock() + text := string(p) + if strings.Contains(text, "\x1b[?1049l") { + r.last = "" + return out.Write(p) + } + if strings.Contains(text, "\x1b[2J") { + r.last = "" + } + var frame bytes.Buffer + frame.WriteString("\x1b[?2026h") + frame.WriteString(withoutPlaceholders(text)) + if r.frame != "" && r.frame != r.last { + // Repaint changed text rows before images. This also erases old Sixels + // when scrolling or opening dialogs, including partially visible bands. + frame.WriteString("\x1b7") + old, lines := strings.Split(r.last, "\n"), strings.Split(r.frame, "\n") + changed := make(map[int]bool) + for y, line := range lines { + if y < len(old) && old[y] == line { + continue + } + changed[y] = true + fmt.Fprintf(&frame, "\x1b[%d;1H\x1b[0m\x1b[2K%s", y+1, withoutPlaceholders(line)) + } + for _, placement := range r.placements { + dirty := false + for y := placement.Y; y < placement.Y+placement.Source.Dy(); y++ { + dirty = dirty || changed[y] + } + if !dirty { + continue + } + encoded, err := r.encode(placement) + if err != nil { + return 0, err + } + fmt.Fprintf(&frame, "\x1b[%d;%dH%s", placement.Y+1, placement.X+1, termgraphics.Wrap(encoded)) + } + frame.WriteString("\x1b8") + } + frame.WriteString("\x1b[?2026l") + if _, err := out.Write(frame.Bytes()); err != nil { + return 0, err + } + r.last = r.frame + return len(p), nil +} + +func (r *sixelRenderer) encode(placement imagePlacement) (string, error) { + // Placement coordinates do not affect the encoded pixels. + placement.X, placement.Y = 0, 0 + if encoded, ok := r.encoded[placement]; ok { + return encoded, nil + } + f, err := os.Open(r.files[placement.ID]) + if err != nil { + return "", err + } + img, err := png.Decode(f) + _ = f.Close() + if err != nil { + return "", err + } + cells := placement.Source + bounds := image.Rect(cells.Min.X*r.cellW, cells.Min.Y*r.cellH, cells.Max.X*r.cellW, cells.Max.Y*r.cellH) + // Normalize to (0,0): the Sixel encoder indexes pixels from the origin. + crop := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + draw.Draw(crop, crop.Bounds(), img, bounds.Min, draw.Src) + var encoded bytes.Buffer + encoded.WriteString("\x1bP0;1q") // Transparent background prevents band padding from erasing adjacent text. + if err := (&sixel.Encoder{}).Encode(&encoded, crop); err != nil { + return "", err + } + encoded.WriteString("\x1b\\") + if len(r.encoded) >= 64 { + clear(r.encoded) + } + r.encoded[placement] = encoded.String() + return encoded.String(), nil +} + +func (r *sixelRenderer) Close(io.Writer) error { + r.Lock() + defer r.Unlock() + clear(r.files) + clear(r.views) + clear(r.encoded) + r.frame, r.last = "", "" + if r.dir == "" { + return nil + } + err := os.RemoveAll(r.dir) + r.dir = "" + return err +} diff --git a/internal/tui/artwork/sixel_test.go b/internal/tui/artwork/sixel_test.go new file mode 100644 index 0000000..73e7b1c --- /dev/null +++ b/internal/tui/artwork/sixel_test.go @@ -0,0 +1,110 @@ +package artwork + +import ( + "bytes" + "image" + "image/color" + "io" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" + "github.com/ygelfand/plexctl/internal/termgraphics" +) + +func TestSixelClippingAndDuplicatePlacements(t *testing.T) { + view := placeholders(7, 4, 4) + lines := strings.Split(view, "\n") + // Clip off the top and left of one image. Put a second copy to its right, + // with an ordinary text cell replacing part of the second row. + first := ansi.Cut(lines[1], 1, 4) + " gap " + lines[0] + second := ansi.Cut(lines[2], 1, 4) + " gap " + ansi.Cut(lines[1], 0, 2) + "X" + ansi.Cut(lines[1], 3, 4) + placements := scanImages(first+"\n"+second, map[int]string{7: "present"}) + want := imagePlacement{ID: 7, X: 0, Y: 0, Source: image.Rect(1, 1, 4, 3)} + if len(placements) != 4 || placements[0] != want { + t.Fatalf("clipped or duplicate image lost: %+v", placements) + } + plain := withoutPlaceholders(first + "\n" + second) + if strings.ContainsRune(plain, kitty.Placeholder) || lipgloss.Width(plain) != 12 || !strings.Contains(plain, "X") { + t.Fatalf("placeholder removal changed layout: %q", plain) + } +} + +func TestSixelOutputLifecycle(t *testing.T) { + t.Setenv("TMUX", "") + renderer, err := newSixelRenderer() + if err != nil { + t.Fatal(err) + } + r := renderer.(*sixelRenderer) + defer func() { _ = r.Close(io.Discard) }() + img := image.NewRGBA(image.Rect(0, 0, 8, 32)) + for y := range 32 { + for x := range 8 { + img.Set(x, y, color.RGBA{R: uint8(y * 7), G: 90, B: 180, A: 255}) + } + } + view, err := r.Render("scoped/item", img, 1, 2) + if err != nil { + t.Fatal(err) + } + // A single 16-pixel row cuts through Sixel's six-pixel bands. + clipped := strings.Split(view, "\n")[1] + r.Frame(clipped) + var output bytes.Buffer + if _, err := r.Write(&output, []byte(clipped)); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "\x1bP0;1q\"1;1;8;16") || strings.ContainsRune(output.String(), kitty.Placeholder) { + t.Fatalf("missing cropped Sixel raster or visible placeholders: %q", output.String()) + } + output.Reset() + if _, err := r.Write(&output, []byte("\x1b[?25l")); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "\x1bP") { + t.Fatal("unchanged image retransmitted") + } + r.Frame("dialog") + output.Reset() + if _, err := r.Write(&output, []byte("dialog")); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "\x1bP") || !strings.Contains(output.String(), "\x1b[2Kdialog") { + t.Fatal("dialog did not replace the image") + } + r.Frame(clipped) + output.Reset() + _, _ = r.Write(&output, []byte(clipped)) + if !strings.Contains(output.String(), "\x1bP") { + t.Fatal("image not restored after dialog closed") + } +} + +func TestRendererSelection(t *testing.T) { + for _, tt := range []struct { + name string + mode string + caps termgraphics.Capabilities + want string + }{ + {name: "ghostty", mode: "auto", caps: termgraphics.Capabilities{Kitty: true, Name: "Ghostty 1.3.1"}, want: "kitty"}, + {name: "sixel terminal", mode: "auto", caps: termgraphics.Capabilities{Sixel: true}, want: "sixel"}, + {name: "unknown", mode: "auto", want: "halfcell"}, + {name: "forced", mode: "sixel", want: "sixel"}, + {name: "fallback requested", mode: "halfcell", caps: termgraphics.Capabilities{Sixel: true}, want: "halfcell"}, + } { + t.Run(tt.name, func(t *testing.T) { + r, err := chooseRenderer(tt.mode, tt.caps) + if err != nil { + t.Fatal(err) + } + defer func() { _ = r.Close(io.Discard) }() + if r.Name() != tt.want { + t.Fatalf("renderer = %s, want %s", r.Name(), tt.want) + } + }) + } +} diff --git a/internal/tui/music_player.go b/internal/tui/music_player.go index 4f36678..1f8b1c7 100644 --- a/internal/tui/music_player.go +++ b/internal/tui/music_player.go @@ -47,6 +47,9 @@ func (c *Controller) refreshPlayer() tea.Cmd { c.visualizer.Step(c.playerStatus.Spectrum, c.playerStatus.Paused(), elapsed) c.visualizer.StepWaveform(c.playerStatus.Waveform, c.playerStatus.Paused(), elapsed) if previous.Active() != c.playerStatus.Active() || previous.AudioOnly != c.playerStatus.AudioOnly { + if previous.Overlay || c.playerStatus.Overlay { + return tea.Batch(resizePlayer(), tea.ClearScreen) + } return resizePlayer() } return nil diff --git a/internal/tui/player/actions.go b/internal/tui/player/actions.go index 5d877a6..c2e9a43 100644 --- a/internal/tui/player/actions.go +++ b/internal/tui/player/actions.go @@ -6,7 +6,6 @@ import ( "log/slog" "net/url" "strings" - "time" "github.com/LukeHagar/plexgo/models/components" tea "github.com/charmbracelet/bubbletea" @@ -15,94 +14,138 @@ import ( "github.com/ygelfand/plexctl/internal/ui" ) -func PlayMedia(metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64, servers ...config.ServerTarget) tea.Cmd { - server := config.MediaServer(servers...) - part := plex.PlayablePart(metadata) - if part == nil { - return func() tea.Msg { return fmt.Errorf("no playable media found") } - } +// PlayItem carries the Plex identity of one playlist entry. The queue is kept +// in playlist order rather than keyed by URL, since a playlist can repeat files. +type PlayItem struct { + URL string + Title string + RatingKey string + Duration float64 +} + +type PlayRequest struct { + Items []PlayItem + Server config.ServerTarget + NoReport bool + Music bool + Subtitles []ExternalSubtitle +} +// BuildPlayRequest resolves containers before starting mpv, so an unsuccessful +// library request cannot interrupt the currently playing item. +func BuildPlayRequest(ctx context.Context, metadata *components.Metadata, noReport bool) (*PlayRequest, error) { + server := config.ServerFromContext(ctx) if server.URL == "" { - return func() tea.Msg { return fmt.Errorf("no active server") } + return nil, fmt.Errorf("no active server") } - - separator := "?" - if strings.Contains(part.Key, "?") { - separator = "&" + if metadata == nil { + return nil, fmt.Errorf("no playable media found") + } + tracks := []components.Metadata{*metadata} + if metadata.Type == "artist" || metadata.Type == "album" { + var err error + tracks, err = plex.CollectTracks(ctx, metadata) + if err != nil { + return nil, err + } } - token := url.QueryEscape(server.Token) - playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", server.URL, part.Key, separator, token) - - var subtitles []ExternalSubtitle - for _, stream := range part.Stream { - if stream.StreamType == components.StreamTypeSubtitle && stream.Key != "" { - sep := "?" - if strings.Contains(stream.Key, "?") { - sep = "&" + req := &PlayRequest{Server: server, NoReport: noReport, Music: config.IsMusic(metadata.Type)} + for _, track := range tracks { + part := plex.PlayablePart(&track) + if part == nil { + return nil, fmt.Errorf("no playable media found") + } + item := PlayItem{URL: partURL(server, part.Key), Title: track.Title} + if track.RatingKey != nil { + item.RatingKey = *track.RatingKey + } + if track.Duration != nil { + item.Duration = float64(*track.Duration) / 1000 + } + if track.Type == "track" && track.GrandparentTitle != nil { + item.Title = *track.GrandparentTitle + " — " + item.Title + } + if noReport { + item.Title = "Trailer: " + item.Title + } + req.Items = append(req.Items, item) + if len(tracks) != 1 { + continue + } + for _, stream := range part.Stream { + if stream.StreamType != components.StreamTypeSubtitle || stream.Key == "" { + continue } - subURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", server.URL, stream.Key, sep, token) - lang := "" + language := "" if stream.LanguageCode != nil { - lang = *stream.LanguageCode + language = *stream.LanguageCode } - subtitles = append(subtitles, ExternalSubtitle{ - URL: subURL, - Title: stream.DisplayTitle, - Language: lang, + req.Subtitles = append(req.Subtitles, ExternalSubtitle{ + URL: partURL(server, stream.Key), Title: stream.DisplayTitle, Language: language, }) } } + return req, nil +} - slog.Debug("Playing media", "output", output, "offset", startOffset, "subtitles", len(subtitles)) - rk := "" - if metadata.RatingKey != nil { - rk = *metadata.RatingKey +func partURL(server config.ServerTarget, key string) string { + separator := "?" + if strings.Contains(key, "?") { + separator = "&" } + return server.URL + key + separator + "X-Plex-Token=" + url.QueryEscape(server.Token) +} - title := metadata.Title - if metadata.Type == "track" && metadata.GrandparentTitle != nil { - title = *metadata.GrandparentTitle + " — " + title - } - if noReport { - title = "Trailer: " + title - } +func PlayMedia(parent context.Context, metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) + pm := GetPlayerManager() + ctx := pm.beginRequest(server.Context(parent)) + return pm.resolveAndPlay(ctx, metadata, noReport, output, startOffset) +} - return GetPlayerManager().Play(playURL, title, rk, noReport, output, startOffset, subtitles, server) +func (pm *PlayerManager) resolveAndPlay(ctx context.Context, metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64) tea.Cmd { + return func() tea.Msg { + req, err := BuildPlayRequest(ctx, metadata, noReport) + if err != nil { + if ctx.Err() == context.Canceled { + return nil + } + return err + } + return pm.playRequest(ctx, req, output, startOffset)() + } } // FetchAndPlay handles the full playback logic: fetch full metadata, check for resume, then play func FetchAndPlay(ratingKey string, output config.VideoOutput, servers ...config.ServerTarget) tea.Cmd { server := config.MediaServer(servers...) + pm := GetPlayerManager() + ctx := pm.beginRequest(server.Context(context.Background())) return func() tea.Msg { - ctx, cancel := context.WithTimeout(server.Context(context.Background()), 30*time.Second) - defer cancel() meta, err := plex.GetMetadata(ctx, ratingKey, true) - if err != nil { - return err + if ctx.Err() == context.Canceled { + return nil } - meta, err = plex.ResolvePlayable(ctx, meta) if err != nil { return err } - if meta.ViewOffset != nil && *meta.ViewOffset > 0 { + if meta.Type != "artist" && meta.Type != "album" && meta.ViewOffset != nil && *meta.ViewOffset > 0 { return ui.ResumeChoiceMsg{Server: server, Metadata: meta, Output: output, } } - cmd := PlayMedia(meta, false, output, 0, server) - if cmd != nil { - return cmd() - } - return nil + return pm.resolveAndPlay(ctx, meta, false, output, 0)() } } // FetchAndPlayTrailer handles finding and playing the correct trailer for a media item -func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOutput, servers ...config.ServerTarget) tea.Cmd { +func FetchAndPlayTrailer(parent context.Context, parentMeta *components.Metadata, output config.VideoOutput, servers ...config.ServerTarget) tea.Cmd { server := config.MediaServer(servers...) + pm := GetPlayerManager() + ctx := pm.beginRequest(server.Context(parent)) return func() tea.Msg { targetKey := "" if parentMeta.PrimaryExtraKey != nil { @@ -128,15 +171,14 @@ func FetchAndPlayTrailer(parentMeta *components.Metadata, output config.VideoOut slog.Debug("FetchAndPlayTrailer: Resolved trailer key", "parent", parentMeta.Title, "target", ratingKey) - meta, err := plex.GetMetadata(server.Context(context.Background()), ratingKey, false) + meta, err := plex.GetMetadata(ctx, ratingKey, false) + if ctx.Err() == context.Canceled { + return nil + } if err != nil { return err } - cmd := PlayMedia(meta, true, output, 0, server) - if cmd != nil { - return cmd() - } - return nil + return pm.resolveAndPlay(ctx, meta, true, output, 0)() } } diff --git a/internal/tui/player/actions_queue_test.go b/internal/tui/player/actions_queue_test.go new file mode 100644 index 0000000..574aef2 --- /dev/null +++ b/internal/tui/player/actions_queue_test.go @@ -0,0 +1,162 @@ +package player + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/ygelfand/plexctl/internal/config" +) + +func queueRequestConnection(t *testing.T) (*ipcClient, <-chan string) { + t.Helper() + left, right := net.Pipe() + loaded := make(chan string, 16) + done := make(chan struct{}) + go func() { + defer close(done) + defer func() { _ = right.Close() }() + decoder, encoder := json.NewDecoder(right), json.NewEncoder(right) + for { + var request struct { + ID uint64 `json:"request_id"` + Command json.RawMessage `json:"command"` + } + if decoder.Decode(&request) != nil { + return + } + var data any + var command []any + quit := false + if json.Unmarshal(request.Command, &command) == nil { + if len(command) > 0 { + quit = command[0] == "quit" + } + if len(command) > 1 && command[0] == "get_property" { + switch command[1] { + case "mpv-version": + data = "queue-test" + case "playlist": + data = []map[string]any{{"id": 101, "current": true}} + } + } + } else { + var named struct { + Name string `json:"name"` + URL string `json:"url"` + } + if json.Unmarshal(request.Command, &named) == nil && named.Name == "loadfile" { + loaded <- named.URL + if encoder.Encode(terminalEvent{Event: "start-file", PlaylistEntryID: 101}) != nil || + encoder.Encode(terminalEvent{Event: "file-loaded"}) != nil { + return + } + } + } + if encoder.Encode(map[string]any{"request_id": request.ID, "error": "success", "data": data}) != nil || quit { + return + } + } + }() + t.Cleanup(func() { _ = left.Close(); _ = right.Close(); <-done }) + return &ipcClient{conn: left, encoder: json.NewEncoder(left), decoder: json.NewDecoder(left)}, loaded +} + +func TestResolveAndPlayCancelsObsoleteQueue(t *testing.T) { + for _, action := range []string{"stop", "new selection"} { + t.Run(action, func(t *testing.T) { + isolatePlayerCache(t) + // A regression must not launch a real player when the old request wins. + t.Setenv("PATH", "") + started, release := make(chan struct{}), make(chan struct{}) + var startedOnce, releaseOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/library/metadata/album/children" { + t.Errorf("unexpected Plex request: %s", r.URL.Path) + http.NotFound(w, r) + return + } + startedOnce.Do(func() { close(started) }) + select { + case <-release: + case <-r.Context().Done(): + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"old","type":"track","title":"Old track","Media":[{"Part":[{"key":"/old.flac"}]}]}]}}`)) + })) + t.Cleanup(server.Close) + target := config.ServerTarget{ID: "cancel-queue", URL: server.URL, Token: "test-token"} + pm := newPlayerManager("") + var loaded <-chan string + pm.conn, loaded = queueRequestConnection(t) + pm.ensureReporter() + oldContext := pm.beginRequest(target.Context(t.Context())) + key := "album" + command := pm.resolveAndPlay(oldContext, &components.Metadata{RatingKey: &key, Type: "album"}, true, config.VideoWindow, 0) + result, finished := make(chan any, 1), make(chan struct{}) + go func() { defer close(finished); result <- command() }() + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + pm.Close() + <-finished + }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("album resolution did not start") + } + switch action { + case "stop": + if message := pm.StopPlayback()(); message != nil { + t.Fatalf("stop: %v", message) + } + case "new selection": + current := pm.beginRequest(target.Context(t.Context())) + key := "new" + track := &components.Metadata{RatingKey: &key, Type: "track", Title: "New track", + Media: []components.Media{{Part: []components.Part{{Key: "/new.flac"}}}}} + if message := pm.resolveAndPlay(current, track, true, config.VideoWindow, 0)(); message != nil { + t.Fatalf("new selection: %v", message) + } + if current.Err() != nil || pm.Status().Key != "new" { + t.Fatal("new selection did not become active") + } + } + if oldContext.Err() != context.Canceled { + t.Errorf("superseded resolution was not canceled: %v", oldContext.Err()) + } + releaseOnce.Do(func() { close(release) }) + select { + case message := <-result: + if message != nil { + t.Fatalf("obsolete request produced a user-visible result: %v", message) + } + case <-time.After(time.Second): + t.Fatal("obsolete request did not finish after cancellation") + } + if action == "stop" { + if status := pm.Status(); status.Active() { + t.Fatal("old queue restarted playback after stop") + } + } else if pm.Status().Key != "new" { + t.Fatal("old queue replaced the newer selection") + } + for { + select { + case mediaURL := <-loaded: + if action == "stop" || mediaURL != partURL(target, "/new.flac") { + t.Errorf("obsolete queue reached mpv: %s", mediaURL) + } + default: + return + } + } + }) + } +} diff --git a/internal/tui/player/ipc.go b/internal/tui/player/ipc.go index df7679f..6f103ed 100644 --- a/internal/tui/player/ipc.go +++ b/internal/tui/player/ipc.go @@ -1,6 +1,7 @@ package player import ( + "context" "encoding/json" "fmt" "net" @@ -14,6 +15,7 @@ type ipcClient struct { encoder *json.Encoder decoder *json.Decoder nextID uint64 + events []terminalEvent } func openIPC(socket string) (*ipcClient, error) { @@ -34,6 +36,7 @@ func (c *ipcClient) command(command any, result any) error { } for { var reply struct { + terminalEvent ID uint64 `json:"request_id"` Error string `json:"error"` Data json.RawMessage `json:"data"` @@ -41,6 +44,9 @@ func (c *ipcClient) command(command any, result any) error { if err := c.decoder.Decode(&reply); err != nil { return err } + if reply.Event == "start-file" || reply.Event == "end-file" || reply.Event == "file-loaded" { + c.events = append(c.events, reply.terminalEvent) + } if reply.ID != c.nextID { continue } @@ -54,6 +60,41 @@ func (c *ipcClient) command(command any, result any) error { } } +// loadfile's acknowledgement precedes decoder/VO initialization. Check the +// actual result while the first item is paused, before declaring success. +func (c *ipcClient) waitInitialFile(ctx context.Context, video bool) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + if err := c.get("idle-active", nil); err != nil { + return fmt.Errorf("mpv disconnected while opening the stream: %w", err) + } + loaded := false + for _, event := range c.events { + if event.Event == "end-file" && event.Reason == "error" { + return fmt.Errorf("mpv could not open the stream or initialize the video output") + } + loaded = loaded || event.Event == "file-loaded" + } + if loaded { + configured := !video + if video { + _ = c.get("vo-configured", &configured) + } + if configured { + return nil + } + } + select { + case <-ctx.Done(): + return fmt.Errorf("mpv did not finish opening the stream and video output: %w", ctx.Err()) + case <-ticker.C: + } + } +} + func (c *ipcClient) call(args ...any) error { return c.command(args, nil) } diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index 07a8115..2675e9e 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -9,28 +9,42 @@ import ( "path/filepath" "runtime" "sync" + "sync/atomic" "time" "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/termgraphics" ) type PlayerManager struct { // ioMu serializes commands and lifecycle changes. Rendering only takes // statusMu, so a slow IPC request never blocks the terminal UI. - ioMu sync.Mutex - conn *ipcClient - cancel context.CancelFunc - socketPath string - waveformDir string - statusMu sync.RWMutex - status PlayerStatus - updates chan tea.Msg - done chan struct{} - closeOnce sync.Once - workers sync.WaitGroup + ioMu sync.Mutex + conn *ipcClient + overlay atomic.Pointer[termgraphics.OverlayWriter] + redrawOverlay atomic.Bool + overlayProcess *os.Process + output config.VideoOutput + queue []queueEntry // guarded by ioMu; mpv playlist IDs survive track changes + cancel context.CancelFunc + socketPath string + waveformDir string + statusMu sync.RWMutex + status PlayerStatus + updates chan tea.Msg + done chan struct{} + closeOnce sync.Once + workers sync.WaitGroup + requestMu sync.Mutex + requestCancel context.CancelFunc + reportOnce sync.Once + reports chan reportBatch + reporterDone chan struct{} + reportCtx context.Context + reportCancel context.CancelFunc } var ( @@ -52,7 +66,27 @@ func GetPlayerManager() *PlayerManager { } func newPlayerManager(socket string) *PlayerManager { - return &PlayerManager{socketPath: socket, updates: make(chan tea.Msg, 1), done: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + return &PlayerManager{socketPath: socket, updates: make(chan tea.Msg, 1), done: make(chan struct{}), + reports: make(chan reportBatch, 64), reportCtx: ctx, reportCancel: cancel} +} + +// Register selections when the command is created, before Bubble Tea schedules +// it. Slow metadata requests must never replace a newer selection or a stop. +func (pm *PlayerManager) beginRequest(parent context.Context) context.Context { + pm.requestMu.Lock() + defer pm.requestMu.Unlock() + if pm.requestCancel != nil { + pm.requestCancel() + } + ctx, cancel := context.WithTimeout(parent, 2*time.Minute) + pm.requestCancel = cancel + select { + case <-pm.done: + cancel() + default: + } + return ctx } func (pm *PlayerManager) Status() PlayerStatus { @@ -77,81 +111,246 @@ func (pm *PlayerManager) Play( url, title, ratingKey string, noReport bool, output config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget, ) tea.Cmd { - server := config.MediaServer(servers...) - if output != config.VideoWindow && output != config.AudioTerminal { - return pm.playTerminal(url, title, ratingKey, noReport, output, startOffset, subtitles, server) + return pm.PlayRequest(&PlayRequest{ + Items: []PlayItem{{URL: url, Title: title, RatingKey: ratingKey}}, + Server: config.MediaServer(servers...), NoReport: noReport, Subtitles: subtitles, + }, output, startOffset) +} + +func (pm *PlayerManager) PlayRequest(req *PlayRequest, output config.VideoOutput, startOffset int64) tea.Cmd { + return pm.playRequest(pm.beginRequest(context.Background()), req, output, startOffset) +} + +func (pm *PlayerManager) playRequest(ctx context.Context, req *PlayRequest, output config.VideoOutput, startOffset int64) tea.Cmd { + if req == nil || len(req.Items) == 0 { + return func() tea.Msg { return fmt.Errorf("empty playback queue") } + } + if isOverlayOutput(output) && (!config.IsTUI || config.Get().TerminalVideoMode != "overlay") { + return pm.playTerminalRequest(ctx, req, output, startOffset) } + preference := output return func() tea.Msg { - previous, err := pm.start(url, title, ratingKey, noReport, output, startOffset, subtitles, server) + if isOverlayOutput(output) { + _, resolved, err := terminalOutput(ctx, output, os.Getenv) + if err != nil { + return err + } + output = resolved + } + _, err := pm.startRequest(ctx, req, output, startOffset) + if err != nil && ctx.Err() == nil && preference == config.VideoAuto && output != config.VideoTCT { + if _, _, probeErr := terminalOutput(ctx, config.VideoTCT, os.Getenv); probeErr == nil { + if _, retryErr := pm.startRequest(ctx, req, config.VideoTCT, startOffset); retryErr == nil { + pm.sendUpdate() + return VideoFallbackMsg{From: output} + } + } + } if err != nil { + if ctx.Err() == context.Canceled { + return nil + } return err } - if previous.Active() { - previous.State = operations.StateStopped - reportProgress(context.Background(), previous) - } pm.sendUpdate() - reportProgress(context.Background(), pm.Status()) return nil } } -func (pm *PlayerManager) start( - url, title, ratingKey string, noReport bool, output config.VideoOutput, - startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget, -) (PlayerStatus, error) { - server := config.MediaServer(servers...) +func (pm *PlayerManager) startRequest(ctx context.Context, req *PlayRequest, output config.VideoOutput, startOffset int64) (PlayerStatus, error) { pm.ioMu.Lock() defer pm.ioMu.Unlock() previous := pm.Status() + if err := ctx.Err(); err != nil { + return previous, err + } select { case <-pm.done: return previous, fmt.Errorf("music player is closed") default: } - if err := pm.connect(); err != nil { + if pm.conn != nil { + _ = pm.conn.get("idle-active", nil) + pm.enqueueReports(pm.queueEvents(&previous)) + pm.setStatus(previous) + } + if err := pm.connect(output); err != nil { return previous, err } + failed := true + defer func() { + if failed { + _ = pm.conn.call("stop") + pm.finishPrevious(&previous) + pm.queue = nil + pm.setStatus(PlayerStatus{}) + } + }() audioOnly := output == config.AudioTerminal - // Remove only our filter; preserve the user's other audio filters. _ = pm.conn.call("af", "remove", "@plexctl-spectrum") if audioOnly { - // Minimal builds may lack lavfi. Playback still works without meters. _ = pm.conn.call("af", "add", spectrumFilter()) _ = pm.ensureWaveformScript() } - if err := pm.conn.command(mediaLoadCommand(url, startOffset, audioOnly), nil); err != nil { + // Pause while constructing the playlist. Boundary events are also buffered + // because a decoder can finish a tiny file even before audio is unpaused. + for i, item := range req.Items { + if err := ctx.Err(); err != nil { + return previous, err + } + offset := int64(0) + if i == 0 { + offset = startOffset + } + command := mediaLoadCommand(item.URL, offset, audioOnly) + options := command["options"].(map[string]string) + options["lavfi-complex"] = "" + if isOverlayOutput(output) { + options["vo"], options["force-window"] = string(output), "no" + } + if req.Music && !audioOnly { + options["lavfi-complex"] = musicVideoGraph(config.Get().WindowMusicVisualizer) + } + options["force-media-title"] = item.Title + options["pause"] = "yes" + if i > 0 { + command["flags"] = "append" + options["pause"] = "no" + } + if err := pm.conn.command(command, nil); err != nil { + return previous, err + } + } + var playlist []struct { + ID int64 `json:"id"` + } + if err := pm.conn.get("playlist", &playlist); err != nil || len(playlist) != len(req.Items) { + return previous, fmt.Errorf("mpv did not accept the complete playback queue") + } + expectVideo := isOverlayOutput(output) && (!req.Music || musicVideoGraph(config.Get().WindowMusicVisualizer) != "") + if err := pm.conn.waitInitialFile(ctx, expectVideo); err != nil { return previous, err } - _ = pm.conn.call("set_property", "pause", false) - _ = pm.conn.call("set_property", "force-media-title", title) - _ = pm.conn.call("set_property", "user-data/plex-rating-key", ratingKey) - _ = pm.conn.call("set_property", "user-data/plex-server-id", server.ID) - _ = pm.conn.call("set_property", "user-data/plex-server-url", server.URL) - _ = pm.conn.call("set_property", "user-data/plex-title", title) - _ = pm.conn.call("set_property", "user-data/plex-no-report", noReport) + pm.finishPrevious(&previous) + pm.queue = make([]queueEntry, len(req.Items)) + for i, item := range req.Items { + pm.queue[i] = queueEntry{ID: playlist[i].ID, PlayItem: item} + } + _ = pm.conn.call("set_property", "user-data/plex-queue", pm.queue) + _ = pm.conn.call("set_property", "user-data/plex-server-id", req.Server.ID) + _ = pm.conn.call("set_property", "user-data/plex-server-url", req.Server.URL) + _ = pm.conn.call("set_property", "user-data/plex-no-report", req.NoReport) _ = pm.conn.call("set_property", "user-data/plex-audio-only", audioOnly) - for _, sub := range subtitles { + for _, sub := range req.Subtitles { _ = pm.conn.call("sub-add", sub.URL, "auto", sub.Title, sub.Language) } - pm.setStatus(PlayerStatus{Server: server, - Title: title, File: url, Key: ratingKey, NoReport: noReport, AudioOnly: audioOnly, - Time: float64(startOffset) / 1000, State: operations.StatePlaying, Volume: previous.Volume, - }) + status := PlayerStatus{Server: req.Server, NoReport: req.NoReport, AudioOnly: audioOnly, Overlay: isOverlayOutput(output), + State: operations.StatePlaying, Volume: previous.Volume} + setTrack(&status, req.Items[0]) + status.Time = float64(startOffset) / 1000 + pm.setStatus(status) + pm.persistTrack(status) + // Even a paused decoder can reach EOF for a very short file. Retain new + // playlist boundaries buffered while building or initializing the queue. + _ = pm.conn.call("set_property", "pause", false) + pm.enqueueReports([]PlayerStatus{status}) + failed = false return previous, nil } +// Read old playlist boundaries before replacing their ID mapping. Completion +// can occur between the monitor tick and the user's next selection. +func (pm *PlayerManager) finishPrevious(status *PlayerStatus) { + boundaries := pm.consumeQueueEvents(status, true) + if status.Active() { + status.State = operations.StateStopped + boundaries = append(boundaries, *status) + } + pm.enqueueReports(boundaries) +} + +type queueEntry struct { + ID int64 + PlayItem +} + +func setTrack(status *PlayerStatus, item PlayItem) { + status.File, status.Key, status.Title = item.URL, item.RatingKey, item.Title + status.Time, status.Duration = 0, item.Duration + status.Spectrum, status.Waveform = Spectrum{}, Waveform{} +} + +func (pm *PlayerManager) persistTrack(status PlayerStatus) { + _ = pm.conn.call("set_property", "force-media-title", status.Title) + _ = pm.conn.call("set_property", "user-data/plex-rating-key", status.Key) + _ = pm.conn.call("set_property", "user-data/plex-title", status.Title) +} + +// queueEvents consumes actual mpv file boundaries rather than inferring EOF +// from polling. Even tracks shorter than the monitor interval get a final +// timeline report. Called only while ioMu is held. +func (pm *PlayerManager) queueEvents(status *PlayerStatus) []PlayerStatus { + return pm.consumeQueueEvents(status, false) +} + +func (pm *PlayerManager) consumeQueueEvents(status *PlayerStatus, preserveUnknown bool) []PlayerStatus { + reports := []PlayerStatus{} + events := pm.conn.events + pm.conn.events = nil + for _, event := range events { + var entry *queueEntry + for i := range pm.queue { + if pm.queue[i].ID == event.PlaylistEntryID { + entry = &pm.queue[i] + break + } + } + if entry == nil { + if preserveUnknown && event.Event != "file-loaded" { + pm.conn.events = append(pm.conn.events, event) + } + continue + } + switch event.Event { + case "start-file": + setTrack(status, entry.PlayItem) + status.Play() + reports = append(reports, *status) + case "end-file": + ended := *status + if ended.Key != entry.RatingKey || ended.File != entry.URL { + setTrack(&ended, entry.PlayItem) + } + if event.Reason == "eof" && ended.Duration > 0 { + ended.Time = ended.Duration + } + ended.State = operations.StateStopped + reports = append(reports, ended) + *status = ended + } + } + return reports +} + // connect is called with ioMu held. Connection identity prevents a stale // monitor from clearing a replacement player. -func (pm *PlayerManager) connect() error { +func (pm *PlayerManager) connect(output config.VideoOutput) error { + if pm.conn != nil && output != pm.output && (isOverlayOutput(output) || isOverlayOutput(pm.output)) { + _ = pm.conn.call("quit") + pm.disconnect() + } + pm.output = output if pm.conn != nil && pm.conn.get("mpv-version", nil) == nil { return nil } pm.disconnect() if conn, err := openIPC(pm.socketPath); err == nil { if conn.get("mpv-version", nil) == nil { - pm.attach(conn) - return nil + if !isOverlayOutput(output) { + pm.attach(conn) + return nil + } + _ = conn.call("quit") } _ = conn.conn.Close() } @@ -165,11 +364,28 @@ func (pm *PlayerManager) connect() error { // Embedded album art must never create a window for in-terminal music. cmd := exec.Command(mpv, "--idle=yes", "--no-resume-playback", "--force-window=no", "--terminal=no", "--input-terminal=no", "--input-ipc-server="+pm.socketPath) + var overlay *termgraphics.OverlayWriter + if isOverlayOutput(output) { + overlay = pm.configureOverlay(cmd, output) + } if err := cmd.Start(); err != nil { + if overlay != nil { + _ = overlay.Close() + } return fmt.Errorf("start mpv: %w", err) } + if overlay != nil { + pm.overlay.Store(overlay) + pm.overlayProcess = cmd.Process + } exited := make(chan error, 1) - go func() { exited <- cmd.Wait() }() + go func() { + err := cmd.Wait() + if overlay != nil { + _ = overlay.Close() + } + exited <- err + }() deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() retry := time.NewTicker(50 * time.Millisecond) @@ -199,23 +415,84 @@ func (pm *PlayerManager) connect() error { func (pm *PlayerManager) attach(conn *ipcClient) { ctx, cancel := context.WithCancel(context.Background()) pm.conn, pm.cancel = conn, cancel - reports := make(chan PlayerStatus, 4) - pm.workers.Add(2) + pm.ensureReporter() + pm.workers.Add(1) go func() { defer pm.workers.Done() - pm.monitor(ctx, conn, reports) + pm.monitor(ctx, conn) }() - go func() { - defer pm.workers.Done() - for { - select { - case <-ctx.Done(): - return - case status := <-reports: - reportProgress(ctx, status) +} + +// One reporter survives connection replacements. All producers enqueue while +// holding ioMu, so a delayed "playing" request cannot overtake a later "stopped" +// report or the next track's start. HTTP never holds the playback mutex. +func (pm *PlayerManager) ensureReporter() { + pm.reportOnce.Do(func() { + pm.reporterDone = make(chan struct{}) + go func() { + defer close(pm.reporterDone) + for batch := range pm.reports { + for _, status := range batch.statuses { + if pm.reportCtx.Err() == nil { + reportProgress(pm.reportCtx, status) + } + } + if batch.done != nil { + close(batch.done) + } } - } - }() + }() + }) +} + +type reportBatch struct { + statuses []PlayerStatus + done chan struct{} +} + +func (pm *PlayerManager) enqueueReports(batch []PlayerStatus) { + if len(batch) == 0 { + return + } + select { + case pm.reports <- reportBatch{statuses: batch}: + case <-pm.reportCtx.Done(): + } +} + +func (pm *PlayerManager) reportTerminal(_ context.Context, status PlayerStatus) { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + select { + case <-pm.done: + return + default: + } + pm.ensureReporter() + pm.enqueueReports([]PlayerStatus{status}) +} + +func (pm *PlayerManager) flushReports() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan struct{}) + pm.ioMu.Lock() + select { + case <-pm.done: + pm.ioMu.Unlock() + return + default: + } + pm.ensureReporter() + select { + case pm.reports <- reportBatch{done: done}: + case <-ctx.Done(): + } + pm.ioMu.Unlock() + select { + case <-done: + case <-ctx.Done(): + } } func (pm *PlayerManager) Reconnect() tea.Cmd { @@ -239,6 +516,7 @@ func (pm *PlayerManager) Reconnect() tea.Cmd { return nil } var status PlayerStatus + _ = conn.get("user-data/plex-queue", &pm.queue) _ = conn.get("user-data/plex-title", &status.Title) _ = conn.get("user-data/plex-rating-key", &status.Key) _ = conn.get("user-data/plex-no-report", &status.NoReport) @@ -252,6 +530,23 @@ func (pm *PlayerManager) Reconnect() tea.Cmd { if status.Server.ID == "" || status.Server.URL == "" || status.Server.Token == "" { status.NoReport = true // An older player cannot identify its originating server. } + var playlist []struct { + ID int64 `json:"id"` + Current bool `json:"current"` + } + _ = conn.get("playlist", &playlist) + currentID := int64(-1) + for _, item := range playlist { + if item.Current { + currentID = item.ID + } + } + for _, entry := range pm.queue { + if entry.ID == currentID { + setTrack(&status, entry.PlayItem) + break + } + } pm.setStatus(status) pm.attach(conn) return nil @@ -283,20 +578,40 @@ func (pm *PlayerManager) Volume(delta float64) tea.Cmd { return pm.control("add" func (pm *PlayerManager) ToggleMute() tea.Cmd { return pm.control("cycle", "mute") } func (pm *PlayerManager) StopPlayback() tea.Cmd { - return func() tea.Msg { - pm.ioMu.Lock() - previous := pm.Status() - if pm.conn != nil { - _ = pm.conn.call("quit") - } - pm.disconnect() - pm.setStatus(PlayerStatus{}) + ctx := pm.beginRequest(context.Background()) + return func() tea.Msg { return pm.stopCurrent(ctx) } +} + +func (pm *PlayerManager) stopCurrent(ctx context.Context) tea.Msg { + pm.ioMu.Lock() + select { + case <-pm.done: pm.ioMu.Unlock() - previous.State = operations.StateStopped - reportProgress(context.Background(), previous) - pm.sendUpdate() return nil + default: } + if ctx.Err() != nil { + pm.ioMu.Unlock() + return nil + } + previous := pm.Status() + boundaries := []PlayerStatus{} + if pm.conn != nil { + _ = pm.conn.call("quit") + boundaries = pm.queueEvents(&previous) + } + if previous.Active() { + previous.State = operations.StateStopped + boundaries = append(boundaries, previous) + } + if len(boundaries) > 0 && pm.reporterDone != nil { + pm.enqueueReports(boundaries) + } + pm.disconnect() + pm.setStatus(PlayerStatus{}) + pm.ioMu.Unlock() + pm.sendUpdate() + return nil } func (pm *PlayerManager) Stop() tea.Cmd { @@ -307,15 +622,38 @@ func (pm *PlayerManager) Stop() tea.Cmd { // elected to keep running can continue after the TUI exits. func (pm *PlayerManager) Close() { pm.closeOnce.Do(func() { - close(pm.done) + // Start the bound before waiting for a producer that may be blocked on + // reporting backpressure while holding ioMu. + timer := time.AfterFunc(3*time.Second, pm.reportCancel) + defer timer.Stop() + defer pm.reportCancel() + pm.requestMu.Lock() + if pm.requestCancel != nil { + pm.requestCancel() + } + pm.requestMu.Unlock() pm.ioMu.Lock() status := pm.Status() - if status.AudioOnly && pm.conn != nil { + if (status.AudioOnly || status.Overlay) && pm.conn != nil { _ = pm.conn.call("quit") + boundaries := pm.queueEvents(&status) + if status.Active() { + status.State = operations.StateStopped + boundaries = append(boundaries, status) + } + if len(boundaries) > 0 { + pm.enqueueReports(boundaries) + } } + close(pm.done) pm.disconnect() + pm.setStatus(PlayerStatus{}) pm.ioMu.Unlock() pm.workers.Wait() + if pm.reporterDone != nil { + close(pm.reports) + <-pm.reporterDone + } if pm.waveformDir != "" { _ = os.RemoveAll(pm.waveformDir) } @@ -323,20 +661,29 @@ func (pm *PlayerManager) Close() { } func (pm *PlayerManager) disconnect() { + if overlay := pm.overlay.Swap(nil); overlay != nil { + _ = overlay.Close() + } + if pm.overlayProcess != nil { + _ = pm.overlayProcess.Kill() + pm.overlayProcess = nil + } if pm.cancel != nil { pm.cancel() pm.cancel = nil } + pm.queue = nil if pm.conn != nil { _ = pm.conn.conn.Close() pm.conn = nil } } -func (pm *PlayerManager) monitor(ctx context.Context, conn *ipcClient, reports chan PlayerStatus) { +func (pm *PlayerManager) monitor(ctx context.Context, conn *ipcClient) { ticker := time.NewTicker(50 * time.Millisecond) defer ticker.Stop() lastReport := time.Now() + lastRedraw := time.Now() for { select { case <-ctx.Done(): @@ -351,50 +698,70 @@ func (pm *PlayerManager) monitor(ctx context.Context, conn *ipcClient, reports c status := previous var idle, paused bool err := conn.get("idle-active", &idle) + boundaries := pm.queueEvents(&status) + sample := status if err == nil && !idle { _ = conn.get("pause", &paused) - status.State = operations.StatePlaying + sample.State = operations.StatePlaying if paused { - status.State = operations.StatePaused + sample.State = operations.StatePaused } - _ = conn.get("time-pos", &status.Time) - _ = conn.get("duration", &status.Duration) - _ = conn.get("volume", &status.Volume) - _ = conn.get("mute", &status.Muted) - if status.AudioOnly && !paused { + _ = conn.get("time-pos", &sample.Time) + _ = conn.get("duration", &sample.Duration) + _ = conn.get("volume", &sample.Volume) + _ = conn.get("mute", &sample.Muted) + if sample.AudioOnly && !paused { var metadata map[string]string _ = conn.get("af-metadata/plexctl-spectrum", &metadata) - status.Spectrum = readSpectrum(metadata) - status.Waveform = Waveform{} - _ = conn.get("user-data/plexctl-waveform", &status.Waveform) + sample.Spectrum = readSpectrum(metadata) + sample.Waveform = Waveform{} + _ = conn.get("user-data/plexctl-waveform", &sample.Waveform) } } else { - status.State = operations.StateStopped - status.Spectrum = Spectrum{} - status.Waveform = Waveform{} + sample.State = operations.StateStopped + sample.Spectrum, sample.Waveform = Spectrum{}, Waveform{} + } + // IPC can deliver file boundaries between any two property replies. + // Discard that mixed sample instead of attributing a new duration or + // position to the old song's rating key. + later := pm.queueEvents(&status) + if len(later) == 0 { + status = sample + } + boundaries = append(boundaries, later...) + if previous.Key != status.Key || previous.File != status.File { + pm.persistTrack(status) } pm.setStatus(status) + if status.Active() && status.Overlay && (pm.redrawOverlay.Swap(false) || status.Paused() && time.Since(lastRedraw) >= 250*time.Millisecond) { + // A paused VO otherwise never repaints after a dialog or resize. + _ = conn.call("osd-overlay", 2, "ass-events", "", 1280, 720) + lastRedraw = time.Now() + } if err != nil { pm.disconnect() + } else if idle && status.Overlay { + _ = conn.call("quit") + pm.disconnect() } - pm.ioMu.Unlock() - if previous.State != status.State { + changed := previous.State != status.State || previous.Key != status.Key + if changed { pm.sendUpdate() } - if previous.State != status.State || time.Since(lastReport) >= 10*time.Second { - // Plex HTTP latency must never stall visualization or controls. + if len(boundaries) > 0 { + // Preserve EOF reports even after disconnect canceled ctx. The + // reporter drains this channel before shutting down. + pm.enqueueReports(boundaries) + } + if changed || time.Since(lastReport) >= 10*time.Second || err != nil && previous.Active() { select { - case reports <- status: + case pm.reports <- reportBatch{statuses: []PlayerStatus{status}}: default: } lastReport = time.Now() } - if err != nil { - // The connection context has been canceled; report the final - // position with its own bounded request after an external exit. - if previous.Active() { - reportProgress(context.Background(), status) - } + pm.ioMu.Unlock() + if err != nil || ctx.Err() != nil { return } } diff --git a/internal/tui/player/manager_integration_test.go b/internal/tui/player/manager_integration_test.go new file mode 100644 index 0000000..db59270 --- /dev/null +++ b/internal/tui/player/manager_integration_test.go @@ -0,0 +1,168 @@ +//go:build integration && !windows + +package player + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/config" +) + +func queueTestAudio(t *testing.T, dir string) string { + t.Helper() + const rate, frames = 48000, 960 // 20 ms, shorter than a player monitor tick. + var wav bytes.Buffer + wav.WriteString("RIFF") + _ = binary.Write(&wav, binary.LittleEndian, uint32(36+frames*4)) + wav.WriteString("WAVEfmt ") + for _, value := range []any{uint32(16), uint16(1), uint16(2), uint32(rate), uint32(rate * 4), uint16(4), uint16(16)} { + _ = binary.Write(&wav, binary.LittleEndian, value) + } + wav.WriteString("data") + _ = binary.Write(&wav, binary.LittleEndian, uint32(frames*4)) + wav.Write(make([]byte, frames*4)) + path := filepath.Join(dir, "short.wav") + if err := os.WriteFile(path, wav.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestPlaybackQueueReportsEveryShortRepeatedPath(t *testing.T) { + isolatePlayerCache(t) + mpv, err := exec.LookPath("mpv") + if err != nil { + t.Skip("mpv is not installed") + } + // Unix socket paths need to remain short even under long test artifact roots. + dir, err := os.MkdirTemp("", "plexctl-queue-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + media := queueTestAudio(t, dir) + socket := filepath.Join(dir, "ipc") + cmd := exec.Command(mpv, "--no-config", "--ao=null", "--vo=null", "--idle=yes", + "--force-window=no", "--input-ipc-server="+socket) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(socket); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("mpv did not open its IPC socket") + } + time.Sleep(10 * time.Millisecond) + } + server, reports := timelineServer(t) + request := &PlayRequest{Server: server, Music: true} + for i := range 8 { + request.Items = append(request.Items, PlayItem{ + URL: media, Title: fmt.Sprintf("Track %d", i), RatingKey: fmt.Sprint(i), Duration: .02, + }) + } + pm := newPlayerManager(socket) + t.Cleanup(func() { pm.StopPlayback()(); pm.Close() }) + if message := pm.PlayRequest(request, config.AudioTerminal, 0)(); message != nil { + t.Fatalf("start queue: %v", message) + } + completed := make(map[string]bool) + timeout := time.NewTimer(6 * time.Second) + defer timeout.Stop() + for len(completed) < len(request.Items) { + select { + case report := <-reports: + if report.state != "stopped" { + continue + } + if report.time != 20 || report.duration != 20 { + t.Errorf("incorrect final timeline: %+v", report) + } + completed[report.key] = true + case <-timeout.C: + status := pm.Status() + t.Fatalf("missing track completions: got %v; state=%q key=%q time=%g", completed, status.State, status.Key, status.Time) + } + } + for _, item := range request.Items { + if !completed[item.RatingKey] { + t.Errorf("missing completion for track %q", item.RatingKey) + } + } +} + +func TestReconnectUsesCurrentPlaylistIDForRepeatedPath(t *testing.T) { + isolatePlayerCache(t) + dir, err := os.MkdirTemp("", "plexctl-reconnect-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "ipc") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + media := "https://plex.invalid/repeated.flac?X-Plex-Token=test-token" + first := PlayItem{URL: media, RatingKey: "first", Title: "First", Duration: 1} + second := PlayItem{URL: media, RatingKey: "second", Title: "Second", Duration: 2} + properties := map[string]any{ + "mpv-version": "queue-test", + "user-data/plex-queue": []queueEntry{{ID: 10, PlayItem: first}, {ID: 11, PlayItem: second}}, + "user-data/plex-title": "First", + "user-data/plex-rating-key": "first", + "user-data/plex-no-report": true, + "user-data/plex-audio-only": false, + "path": media, + "user-data/plex-server-id": "queue-test", + "user-data/plex-server-url": "https://plex.invalid", + "playlist": []map[string]any{{"id": 10}, {"id": 11, "current": true}}, + } + peerDone := make(chan struct{}) + go func() { + defer close(peerDone) + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + decoder, encoder := json.NewDecoder(conn), json.NewEncoder(conn) + for { + var request struct { + ID uint64 `json:"request_id"` + Command []string `json:"command"` + } + if decoder.Decode(&request) != nil { + return + } + var data any + if len(request.Command) > 1 { + data = properties[request.Command[1]] + } + if encoder.Encode(map[string]any{"request_id": request.ID, "error": "success", "data": data}) != nil { + return + } + } + }() + pm := newPlayerManager(socket) + t.Cleanup(func() { pm.Close(); _ = listener.Close(); <-peerDone }) + if message := pm.Reconnect()(); message != nil { + t.Fatalf("reconnect: %v", message) + } + if status := pm.Status(); status.Key != "second" || status.Title != "Second" || status.Duration != 2 { + t.Fatalf("reattached to wrong occurrence: key=%q title=%q duration=%g", status.Key, status.Title, status.Duration) + } +} diff --git a/internal/tui/player/manager_reporting_test.go b/internal/tui/player/manager_reporting_test.go new file mode 100644 index 0000000..a4324c6 --- /dev/null +++ b/internal/tui/player/manager_reporting_test.go @@ -0,0 +1,236 @@ +package player + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/config" +) + +func isolatePlayerCache(t *testing.T) { + t.Helper() + cfg := config.Get() + cacheDir, noCache := cfg.CacheDir, cfg.NoCache + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + t.Cleanup(func() { cfg.CacheDir, cfg.NoCache = cacheDir, noCache }) +} + +type timelineReport struct { + key, state string + time, duration int64 +} + +func readTimeline(r *http.Request) timelineReport { + query := r.URL.Query() + position, _ := strconv.ParseInt(query.Get("time"), 10, 64) + duration, _ := strconv.ParseInt(query.Get("duration"), 10, 64) + return timelineReport{key: query.Get("ratingKey"), state: query.Get("state"), time: position, duration: duration} +} + +func timelineServer(t *testing.T) (config.ServerTarget, <-chan timelineReport) { + t.Helper() + reports := make(chan timelineReport, 256) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reports <- readTimeline(r) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{}}`)) + })) + t.Cleanup(server.Close) + return config.ServerTarget{ID: "queue-test", URL: server.URL, Token: "test-token"}, reports +} + +func TestMonitorKeepsTrackIdentityWhenBoundaryArrivesDuringSample(t *testing.T) { + isolatePlayerCache(t) + server, reports := timelineServer(t) + left, right := net.Pipe() + pm := newPlayerManager("") + first := PlayItem{URL: "first", RatingKey: "one", Duration: 5} + second := PlayItem{URL: "second", RatingKey: "two", Duration: 9} + pm.queue = []queueEntry{{ID: 10, PlayItem: first}, {ID: 11, PlayItem: second}} + status := PlayerStatus{Server: server, State: operations.StatePlaying} + setTrack(&status, first) + status.Time = 4.9 + pm.setStatus(status) + peerDone := make(chan struct{}) + go func() { + defer close(peerDone) + defer func() { _ = right.Close() }() + decoder, encoder := json.NewDecoder(right), json.NewEncoder(right) + sent := false + for { + var request struct { + ID uint64 `json:"request_id"` + Command []any `json:"command"` + } + if decoder.Decode(&request) != nil { + return + } + var data any + if len(request.Command) > 1 && request.Command[0] == "get_property" { + switch request.Command[1] { + case "idle-active", "pause", "mute": + data = false + case "time-pos": + if !sent { + for _, event := range []terminalEvent{ + {Event: "end-file", PlaylistEntryID: 10, Reason: "eof"}, + {Event: "start-file", PlaylistEntryID: 11}, + } { + if encoder.Encode(event) != nil { + return + } + } + sent = true + } + data = 2.0 + case "duration": + data = 9.0 + case "volume": + data = 100.0 + } + } + if encoder.Encode(map[string]any{"request_id": request.ID, "error": "success", "data": data}) != nil { + return + } + } + }() + pm.attach(&ipcClient{conn: left, encoder: json.NewEncoder(left), decoder: json.NewDecoder(left)}) + t.Cleanup(func() { pm.Close(); _ = right.Close(); <-peerDone }) + select { + case report := <-reports: + if report.key != "one" || report.state != "stopped" || report.time != 5000 || report.duration != 5000 { + t.Fatalf("next track's sample changed the previous completion: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("missing first track completion") + } + if got := pm.Status(); got.Key != "two" || got.Duration != 9 { + t.Fatalf("new track identity was not applied: key=%q duration=%g", got.Key, got.Duration) + } +} + +func TestStopPlaybackOrdersFinalReportAfterPendingProgress(t *testing.T) { + isolatePlayerCache(t) + started, release := make(chan struct{}), make(chan struct{}) + stopped := make(chan struct{}, 8) + var first, releaseOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + if state == "playing" { + first.Do(func() { close(started); <-release }) + } + if state == "stopped" { + stopped <- struct{}{} + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{}}`)) + })) + t.Cleanup(server.Close) + pm := newPlayerManager("") + status := PlayerStatus{Server: config.ServerTarget{ID: "ordering-test", URL: server.URL, Token: "test-token"}, + Key: "one", State: operations.StatePlaying} + pm.setStatus(status) + pm.ensureReporter() + pm.ioMu.Lock() + pm.enqueueReports([]PlayerStatus{status}) + pm.ioMu.Unlock() + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(func() { unblock(); pm.Close() }) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("progress report did not start") + } + if message := pm.StopPlayback()(); message != nil { + t.Fatalf("stop: %v", message) + } + select { + case <-stopped: + t.Fatal("stopped reached Plex while an earlier playing report was still in flight") + case <-time.After(50 * time.Millisecond): + } + unblock() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("stop report was lost after the earlier request completed") + } +} + +func TestStopPlaybackAfterClose(t *testing.T) { + pm := newPlayerManager("") + pm.setStatus(PlayerStatus{Key: "one", State: operations.StatePlaying, NoReport: true}) + pm.ensureReporter() + pm.Close() + if message := pm.StopPlayback()(); message != nil { + t.Fatalf("stop after close: %v", message) + } +} + +func TestCloseDeadlineAppliesWhenReportQueueIsFull(t *testing.T) { + isolatePlayerCache(t) + started, release := make(chan struct{}), make(chan struct{}) + var first sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + first.Do(func() { close(started) }) + select { + case <-release: + case <-r.Context().Done(): + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{}}`)) + })) + t.Cleanup(server.Close) + pm := newPlayerManager("") + pm.ensureReporter() + var producerDone, closed chan struct{} + t.Cleanup(func() { + close(release) + if closed != nil { + <-closed + } else { + pm.Close() + } + if producerDone != nil { + <-producerDone + } + }) + slow := PlayerStatus{Server: config.ServerTarget{ID: "shutdown-test", URL: server.URL, Token: "test-token"}, + Key: "one", State: operations.StatePlaying} + pm.ioMu.Lock() + pm.enqueueReports([]PlayerStatus{slow, slow}) + pm.ioMu.Unlock() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timeline request never started") + } + pm.ioMu.Lock() + for range cap(pm.reports) { + pm.enqueueReports([]PlayerStatus{{NoReport: true}}) + } + pm.ioMu.Unlock() + held := make(chan struct{}) + producerDone, closed = make(chan struct{}), make(chan struct{}) + go func() { + defer close(producerDone) + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + close(held) + pm.enqueueReports([]PlayerStatus{{NoReport: true}}) + }() + <-held + go func() { defer close(closed); pm.Close() }() + select { + case <-closed: + case <-time.After(4500 * time.Millisecond): + t.Fatal("shutdown deadline did not cover waiting for a full report queue") + } +} diff --git a/internal/tui/player/manager_test.go b/internal/tui/player/manager_test.go new file mode 100644 index 0000000..4f72461 --- /dev/null +++ b/internal/tui/player/manager_test.go @@ -0,0 +1,45 @@ +package player + +import ( + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/config" +) + +func TestQueueEventsReportEveryTrack(t *testing.T) { + server := config.ServerTarget{ID: "friend", URL: "https://friend.invalid", Token: "private"} + first := PlayItem{URL: "repeated-file", RatingKey: "one", Title: "First", Duration: 3} + second := PlayItem{URL: "repeated-file", RatingKey: "two", Title: "Second", Duration: 4} + pm := newPlayerManager("") + pm.queue = []queueEntry{{ID: 10, PlayItem: first}, {ID: 11, PlayItem: second}} + pm.conn = &ipcClient{events: []terminalEvent{ + {Event: "end-file", PlaylistEntryID: 10, Reason: "eof"}, + {Event: "start-file", PlaylistEntryID: 11}, + {Event: "end-file", PlaylistEntryID: 11, Reason: "eof"}, + {Event: "end-file", PlaylistEntryID: 999, Reason: "eof"}, + }} + status := PlayerStatus{Server: server, AudioOnly: true, NoReport: true, State: operations.StatePlaying} + setTrack(&status, first) + reports := pm.queueEvents(&status) + if len(reports) != 3 { + t.Fatalf("missing track boundaries: %+v", reports) + } + for i, report := range reports { + if report.Server != server || !report.NoReport || !report.AudioOnly { + t.Fatalf("track %d lost playback context: %+v", i, report) + } + } + if reports[0].Key != "one" || reports[0].Time != 3 || reports[0].State != operations.StateStopped { + t.Fatalf("first track not completed: %+v", reports[0]) + } + if reports[1].Key != "two" || reports[1].Time != 0 || reports[1].Title != "Second" { + t.Fatalf("second track not reset: %+v", reports[1]) + } + if status.Key != "two" || status.Time != 4 || status.State != operations.StateStopped { + t.Fatalf("last track not completed: %+v", status) + } + if len(pm.queueEvents(&status)) != 0 { + t.Fatal("track boundaries were reported twice") + } +} diff --git a/internal/tui/player/overlay.go b/internal/tui/player/overlay.go new file mode 100644 index 0000000..72343ff --- /dev/null +++ b/internal/tui/player/overlay.go @@ -0,0 +1,88 @@ +package player + +import ( + "fmt" + "image" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" + "github.com/ygelfand/plexctl/internal/tui/artwork" + "github.com/ygelfand/plexctl/internal/ui" +) + +func isOverlayOutput(output config.VideoOutput) bool { + return output != config.VideoWindow && output != config.AudioTerminal +} + +func overlayBounds() image.Rectangle { + layout := ui.GetLayout() + // Reserve the compact player bar even before its first status update. + height := min(layout.ContentHeight(), max(0, layout.TotalHeight()-7)) + return image.Rect(ui.SidebarTotalWidth+2, 1, ui.SidebarTotalWidth+2+layout.InnerWidth(), 1+height) +} + +func overlayArgs(output config.VideoOutput, widthLimit int) []string { + rect := overlayBounds() + cols, rows := max(1, rect.Dx()), max(2, rect.Dy()) + if output == config.VideoTCT { + return []string{fmt.Sprintf("--vo-tct-width=%d", cols), fmt.Sprintf("--vo-tct-height=%d", rows)} + } + prefix := "--vo-" + string(output) + "-" + caps := termgraphics.Current() + cw, ch := caps.CellWidth, caps.CellHeight + if cw <= 0 || ch <= 0 { + cw, ch = 8, 16 + } + args := []string{prefix + "alt-screen=no", prefix + "config-clear=no", prefix + "left=1", prefix + "top=1", + fmt.Sprintf("%scols=%d", prefix, cols), fmt.Sprintf("%srows=%d", prefix, rows), + fmt.Sprintf("%swidth=%d", prefix, cols*cw), fmt.Sprintf("%sheight=%d", prefix, rows*ch)} + if output == config.VideoSixel { + args = append(args, sixelSizeArgs(widthLimit, cols, rows, cw, ch)...) + } + if output == config.VideoKitty { + args = append(args, prefix+"use-shm=no") + } + return args +} + +func (pm *PlayerManager) configureOverlay(cmd *exec.Cmd, output config.VideoOutput) *termgraphics.OverlayWriter { + w := termgraphics.NewOverlayWriter(artwork.VideoOutput(), overlayBounds, output == config.VideoKitty) + cmd.Stdout = w + cmd.Args = append(cmd.Args, "--vo="+string(output), "--terminal=yes", "--input-terminal=no", + "--really-quiet", "--osc=no", "--profile=sw-fast") + cmd.Args = append(cmd.Args, overlayArgs(output, config.Get().SixelVideoWidth)...) + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "TMUX=") && !strings.HasPrefix(entry, "TMUX_PANE=") { + cmd.Env = append(cmd.Env, entry) + } + } + return w +} + +func (pm *PlayerManager) SetOverlayVisible(visible bool) { + if overlay := pm.overlay.Load(); overlay != nil { + if overlay.SetVisible(visible) && visible { + pm.redrawOverlay.Store(true) + } + } +} + +func (pm *PlayerManager) ResizeOverlay() tea.Cmd { + return func() tea.Msg { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + if pm.overlay.Load() == nil || pm.conn == nil { + return nil + } + for _, arg := range overlayArgs(pm.output, config.Get().SixelVideoWidth) { + option, value, _ := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + _ = pm.conn.call("set_property", option, value) + } + pm.redrawOverlay.Store(true) + return nil + } +} diff --git a/internal/tui/player/overlay_integration_test.go b/internal/tui/player/overlay_integration_test.go new file mode 100644 index 0000000..4184787 --- /dev/null +++ b/internal/tui/player/overlay_integration_test.go @@ -0,0 +1,145 @@ +//go:build integration && !windows + +package player + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/termgraphics" + "github.com/ygelfand/plexctl/internal/tui/artwork" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestOverlayPlaybackWithRealMPV(t *testing.T) { + mpv, err := exec.LookPath("mpv") + if override := os.Getenv("PLEXCTL_TEST_MPV"); override != "" { + mpv, err = exec.LookPath(override) + } + if err != nil { + t.Skip("mpv is not installed") + } + wasTUI, previous := config.IsTUI, *config.Get() + config.IsTUI = true + config.Get().TerminalVideoMode = "overlay" + config.Get().WindowMusicVisualizer = "waves" + config.Get().SixelVideoWidth = 160 + t.Cleanup(func() { config.IsTUI = wasTUI; *config.Get() = previous }) + dir := t.TempDir() + config.Get().CacheDir = dir + media := testAudio(t, dir) + // Force a silent audio sink without depending on the user's mpv settings. + wrapper := filepath.Join(dir, "mpv") + script := "#!/bin/sh\nif [ \"$PLEXCTL_FAIL_KITTY\" = 1 ]; then\n for arg; do [ \"$arg\" = --vo=kitty ] && exit 3; done\nfi\nexec \"$PLEXCTL_REAL_MPV\" --no-config --ao=null \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PLEXCTL_REAL_MPV", mpv) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("TMUX", "") + t.Setenv("TMUX_PANE", "") + layout := ui.GetLayout() + w, h := layout.TotalWidth(), layout.TotalHeight() + layout.Update(78, 24, false) + t.Cleanup(func() { layout.Update(w, h, false) }) + for _, output := range []config.VideoOutput{config.VideoKitty, config.VideoSixel, config.VideoTCT, config.VideoAuto} { + t.Run(string(output), func(t *testing.T) { + if output == config.VideoAuto { + t.Setenv("TERM", "xterm-kitty") + t.Setenv("TERM_PROGRAM", "kitty") + t.Setenv("PLEXCTL_FAIL_KITTY", "1") + } + if _, _, err := terminalOutput(t.Context(), output, os.Getenv); err != nil { + t.Skip(err) + } + file, err := os.CreateTemp(t.TempDir(), "screen") + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + screen, err := artwork.OpenRenderer(file, "halfcell", termgraphics.Capabilities{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = screen.Close() }() + pm := newPlayerManager(filepath.Join(t.TempDir(), "ipc")) + defer pm.Close() + defer func() { pm.StopPlayback()() }() + req := &PlayRequest{Music: true, NoReport: true, Items: []PlayItem{{URL: media, Title: "Overlay", RatingKey: "overlay"}}} + msg := pm.PlayRequest(req, output, 0)() + if output == config.VideoAuto { + if fallback, ok := msg.(VideoFallbackMsg); !ok || fallback.From != config.VideoKitty { + t.Fatalf("missing automatic graphics fallback notification: %v", msg) + } + } else if msg != nil { + t.Fatalf("start overlay: %v", msg) + } + status := waitAudio(t, pm, "overlay starts", func(s PlayerStatus) bool { return s.Overlay && s.Time > .15 }) + if status.AudioOnly { + t.Fatal("overlay lost the visualizer video stream") + } + pm.ioMu.Lock() + var current string + err = pm.conn.get("current-vo", ¤t) + pm.ioMu.Unlock() + want := output + if output == config.VideoAuto { + want = config.VideoTCT + } + if err != nil || current != string(want) { + t.Fatalf("video output = %q, %v", current, err) + } + pm.TogglePause()() + waitAudio(t, pm, "pause overlay", func(s PlayerStatus) bool { return s.Paused() }) + pm.SetOverlayVisible(false) + pm.SetOverlayVisible(true) + before, _ := file.Stat() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + after, _ := file.Stat() + if after.Size() > before.Size() { + break + } + time.Sleep(10 * time.Millisecond) + } + after, _ := file.Stat() + if after.Size() <= before.Size() { + t.Fatal("paused overlay did not repaint after the dialog closed") + } + pm.StopPlayback()() + data, err := os.ReadFile(file.Name()) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 || strings.Contains(string(data), "\x1b[?1049") || strings.Contains(string(data), "\x1b[2J") { + t.Fatal("overlay switched or cleared the TUI screen") + } + }) + } +} + +func TestMusicVideoGraphsWithRealMPV(t *testing.T) { + mpv, err := exec.LookPath("mpv") + if err != nil { + t.Skip("mpv is not installed") + } + media := testAudio(t, t.TempDir()) + for _, style := range []string{"cqt", "spectrum", "waves", "scope"} { + t.Run(style, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, mpv, "--no-config", "--vo=null", "--ao=null", "--length=0.3", "--untimed", + "--lavfi-complex="+musicVideoGraph(style), media) + data, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(data), "VO: [null]") || !strings.Contains(string(data), "AO: [null]") { + t.Fatalf("visualizer did not produce both audio and video: %v\n%s", err, data) + } + }) + } +} diff --git a/internal/tui/player/player_state.go b/internal/tui/player/player_state.go index 181bbf0..e017223 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -1,10 +1,17 @@ package player import ( + "fmt" "github.com/LukeHagar/plexgo/models/operations" "github.com/ygelfand/plexctl/internal/config" ) +type VideoFallbackMsg struct{ From config.VideoOutput } + +func (m VideoFallbackMsg) String() string { + return fmt.Sprintf("%s graphics could not start; using the Text renderer. Select a renderer in Settings to override.", m.From) +} + type PlayerStatus struct { Server config.ServerTarget Time float64 @@ -15,6 +22,7 @@ type PlayerStatus struct { State operations.State NoReport bool AudioOnly bool + Overlay bool Volume float64 Muted bool Spectrum Spectrum @@ -32,9 +40,9 @@ type ( PlayerStatusMsgPoller struct{} ) -func (ps *PlayerStatus) Paused() bool { return ps.State == operations.StatePaused } +func (ps PlayerStatus) Paused() bool { return ps.State == operations.StatePaused } -func (ps *PlayerStatus) Active() bool { +func (ps PlayerStatus) Active() bool { return ps.State != "" && ps.State != operations.StateStopped } diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index da7347a..dbee283 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -36,34 +36,54 @@ type terminalPlayback struct { stdout io.Writer report func(context.Context, PlayerStatus) sharedMemory bool + items []PlayItem + playlist map[int64]PlayItem + visualizer string + sixelWidth int + request context.Context + started bool + attempted bool + fallback config.VideoOutput } var _ tea.ExecCommand = (*terminalPlayback)(nil) -func (pm *PlayerManager) playTerminal(url, title, ratingKey string, noReport bool, preference config.VideoOutput, startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget) tea.Cmd { - server := config.MediaServer(servers...) +func (pm *PlayerManager) playTerminalRequest(ctx context.Context, req *PlayRequest, preference config.VideoOutput, startOffset int64) tea.Cmd { + server := req.Server + item := req.Items[0] return func() tea.Msg { + if ctx.Err() != nil { + return nil + } if !config.IsTUI { termgraphics.Probe(os.Stdin, os.Stdout) } - mpv, output, err := terminalOutput(context.Background(), preference, os.Getenv) + mpv, output, err := terminalOutput(ctx, preference, os.Getenv) if err != nil { return err } playback := &terminalPlayback{ - mpv: mpv, output: output, preference: preference, subtitles: subtitles, report: reportProgress, + mpv: mpv, output: output, preference: preference, subtitles: req.Subtitles, report: pm.reportTerminal, items: req.Items, + sixelWidth: config.Get().SixelVideoWidth, request: ctx, status: PlayerStatus{Server: server, - Title: title, File: url, Key: ratingKey, NoReport: noReport, + Title: item.Title, File: item.URL, Key: item.RatingKey, NoReport: req.NoReport, Duration: item.Duration, Time: float64(startOffset) / 1000, }, } + if req.Music { + playback.visualizer = musicVideoGraph(config.Get().WindowMusicVisualizer) + } // A separate socket prevents a later TUI from attaching to a foreground // player whose terminal belongs to this session. - pm.StopPlayback()() + pm.stopCurrent(ctx) finished := func(err error) tea.Msg { + pm.flushReports() if err != nil { return err } + if playback.fallback != config.VideoWindow { + return VideoFallbackMsg{From: playback.fallback} + } return PlayerStatusMsg{} } if config.IsTUI { @@ -113,10 +133,33 @@ func (p *terminalPlayback) args(socket string) []string { // of the Lua OSC and its window-system mouse events. args = append(args, "--osc=no") } + if p.visualizer != "" { + args = append(args, "--lavfi-complex="+p.visualizer) + } + if p.fallback != config.VideoWindow { + args = append(args, "--osd-playing-msg="+VideoFallbackMsg{From: p.fallback}.String()) + } return args } func (p *terminalPlayback) Run() error { + if p.request != nil && p.request.Err() != nil { + return nil + } + initial := p.status + err := p.run() + if err == nil || p.preference != config.VideoAuto || p.started || !p.attempted || p.output == config.VideoTCT { + return err + } + if _, _, fallbackErr := terminalOutput(context.Background(), config.VideoTCT, os.Getenv); fallbackErr != nil { + return err + } + p.fallback, p.output, p.preference = p.output, config.VideoTCT, config.VideoTCT + p.status, p.playlist = initial, nil + return p.run() +} + +func (p *terminalPlayback) run() error { for _, stream := range []any{p.stdin, p.stdout} { if f, ok := stream.(interface{ Fd() uintptr }); ok && !term.IsTerminal(int(f.Fd())) { return fmt.Errorf("terminal video requires an interactive terminal") @@ -185,6 +228,19 @@ func (p *terminalPlayback) Run() error { cmd.Stdout = graphics defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap(graphics.DeleteImages())) }() } + if p.output == config.VideoSixel { + columns, rows := 80, 24 + cw, ch := termgraphics.Current().CellWidth, termgraphics.Current().CellHeight + if input, ok := p.stdin.(*os.File); ok { + if w, h, err := term.GetSize(int(input.Fd())); err == nil { + columns, rows = w, h + } + if w, h := terminalCellSize(input); w > 0 && h > 0 { + cw, ch = int(w), int(h) + } + } + cmd.Args = append(cmd.Args, sixelSizeArgs(p.sixelWidth, columns, rows, cw, ch)...) + } // Our wrapper handles actual tmux panes. Also remove stale tmux variables // from direct Kitty windows so mpv cannot wrap their graphics by mistake. for _, entry := range os.Environ() { @@ -212,6 +268,7 @@ func (p *terminalPlayback) Run() error { defer func() { _, _ = io.WriteString(p.stdout, "\x1b[?1003l") }() } } + p.attempted = true if err := cmd.Start(); err != nil { return fmt.Errorf("start terminal player: %w", err) } @@ -258,8 +315,32 @@ func (p *terminalPlayback) Run() error { return err } } - // Request ID 1 lets us catch a rejected load even when mpv stays idle. - if err := sendTerminalCommand(conn, 1, "loadfile", p.status.File, "replace"); err != nil { + // Build the queue paused, using per-file options so resume applies only + // to the first item. Playlist IDs identify repeated paths unambiguously. + items := p.items + if len(items) == 0 { + items = []PlayItem{{URL: p.status.File, Title: p.status.Title, RatingKey: p.status.Key}} + } + pending := []terminalEvent{} + for i, item := range items { + offset := 0.0 + flags, pause := "append", "no" + if i == 0 { + flags, pause, offset = "replace", "yes", p.status.Time + } + command := map[string]any{"name": "loadfile", "url": item.URL, "flags": flags, + "options": map[string]string{"start": fmt.Sprintf("%.3f", offset), + "pause": pause, "force-media-title": item.Title}} + if err := sendTerminalRequest(conn, 1, command); err != nil { + return err + } + // Drain acknowledgements while building large artist queues. Otherwise + // a full event channel can block mpv's IPC output and our next write. + if err := awaitQueueLoad(events, &pending); err != nil { + return err + } + } + if err := sendTerminalCommand(conn, 3, "get_property", "playlist"); err != nil { return err } @@ -319,6 +400,72 @@ func (p *terminalPlayback) Run() error { } return nil } + handleEvent := func(ev terminalEvent) error { + if ev.RequestID == 1 && ev.Error != "success" { + return fmt.Errorf("mpv rejected the media stream") + } + if ev.RequestID == 3 { + var playlist []struct { + ID int64 `json:"id"` + } + if ev.Error != "success" || json.Unmarshal(ev.Data, &playlist) != nil || len(playlist) != len(items) { + return fmt.Errorf("mpv did not accept the complete playback queue") + } + p.playlist = make(map[int64]PlayItem, len(items)) + for i, item := range items { + p.playlist[playlist[i].ID] = item + } + if err := sendTerminalCommand(conn, 0, "set_property", "pause", false); err != nil { + return err + } + } + if ev.RequestID == 2 && ev.Error != "success" { + return fmt.Errorf("mpv could not draw video controls; use an mpv build with libass OSD support") + } + switch ev.Event { + case "start-file": + if item, ok := p.playlist[ev.PlaylistEntryID]; ok { + setTrack(&p.status, item) + } + case "file-loaded": + p.started = true + loaded = true + p.status.Play() + for _, sub := range p.subtitles { + if err := sendTerminalCommand(conn, 0, "sub-add", sub.URL, "auto", sub.Title, sub.Language); err != nil { + return err + } + } + p.reportStatus() + case "property-change": + oldState := p.status.State + p.applyProperty(ev) + if controls != nil && len(ev.Data) > 0 && string(ev.Data) != "null" { + switch ev.ID { + case 6: + _ = json.Unmarshal(ev.Data, &controls.speed) + case 7: + _ = json.Unmarshal(ev.Data, &controls.subtitles) + } + } + if loaded && p.status.State != oldState { + p.reportStatus() + } + case "end-file": + if ev.Reason == "error" { + return fmt.Errorf("mpv could not play the stream using %s; check the server connection and terminal graphics support", p.output) + } + if ev.Reason == "eof" && p.status.Duration > 0 { + p.status.Time = p.status.Duration + } + p.status.State = operations.StateStopped + p.reportStatus() + loaded = false + case "shutdown": + // Continue draining properties until the process exits. + } + return nil + } for { select { case data, ok := <-keys: @@ -368,45 +515,22 @@ func (p *terminalPlayback) Run() error { return fmt.Errorf("terminal player disconnected from IPC") } } - if ev.RequestID == 1 && ev.Error != "success" { - return fmt.Errorf("mpv rejected the media stream") + // Decoder events can arrive before playlist discovery, including EOF + // for tiny files while paused. Replay them only after IDs are known. + if p.playlist == nil && ev.Event != "" && (ev.Event != "end-file" || ev.Reason != "error") { + pending = append(pending, ev) + continue } - if ev.RequestID == 2 && ev.Error != "success" { - return fmt.Errorf("mpv could not draw video controls; use an mpv build with libass OSD support") + if err := handleEvent(ev); err != nil { + return err } - switch ev.Event { - case "file-loaded": - loaded = true - p.status.Play() - for _, sub := range p.subtitles { - if err := sendTerminalCommand(conn, 0, "sub-add", sub.URL, "auto", sub.Title, sub.Language); err != nil { + if p.playlist != nil && len(pending) > 0 { + for _, buffered := range pending { + if err := handleEvent(buffered); err != nil { return err } } - p.reportStatus() - case "property-change": - oldState := p.status.State - p.applyProperty(ev) - if controls != nil && len(ev.Data) > 0 && string(ev.Data) != "null" { - switch ev.ID { - case 6: - _ = json.Unmarshal(ev.Data, &controls.speed) - case 7: - _ = json.Unmarshal(ev.Data, &controls.subtitles) - } - } - if loaded && p.status.State != oldState { - p.reportStatus() - } - case "end-file": - if ev.Reason == "error" { - return fmt.Errorf("mpv could not play the stream using %s; check the server connection and terminal graphics support", p.output) - } - if ev.Reason == "eof" && p.status.Duration > 0 { - p.status.Time = p.status.Duration - } - case "shutdown": - // Continue draining properties until the process exits. + pending = nil } case <-ticker.C: if loaded { @@ -416,6 +540,33 @@ func (p *terminalPlayback) Run() error { } } +func awaitQueueLoad(events <-chan terminalEvent, pending *[]terminalEvent) error { + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case event, ok := <-events: + if !ok { + return fmt.Errorf("mpv disconnected while building the playback queue") + } + if event.RequestID == 1 { + if event.Error != "success" { + return fmt.Errorf("mpv rejected the media stream") + } + return nil + } + if event.Event == "end-file" && event.Reason == "error" { + return fmt.Errorf("mpv could not open a queued media stream") + } + if event.Event != "" { + *pending = append(*pending, event) + } + case <-timer.C: + return fmt.Errorf("mpv did not acknowledge the queued media stream") + } + } +} + func (p *terminalPlayback) exitError(err error) error { var exit *exec.ExitError // mpv uses status 4 for a user interrupt (Ctrl+C). @@ -438,12 +589,13 @@ func (p *terminalPlayback) reportStatus() { } type terminalEvent struct { - Event string `json:"event"` - ID int `json:"id"` - Data json.RawMessage `json:"data"` - Reason string `json:"reason"` - RequestID int `json:"request_id"` - Error string `json:"error"` + PlaylistEntryID int64 `json:"playlist_entry_id"` + Event string `json:"event"` + ID int `json:"id"` + Data json.RawMessage `json:"data"` + Reason string `json:"reason"` + RequestID int `json:"request_id"` + Error string `json:"error"` } func (p *terminalPlayback) applyProperty(ev terminalEvent) { @@ -491,14 +643,15 @@ func connectTerminal(socket string, done <-chan struct{}) (net.Conn, error) { } func sendTerminalCommand(conn net.Conn, requestID int, args ...any) error { + return sendTerminalRequest(conn, requestID, args) +} + +func sendTerminalRequest(conn net.Conn, requestID int, command any) error { if err := conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil { return fmt.Errorf("set player IPC deadline: %w", err) } - command := struct { - Command []any `json:"command"` - RequestID int `json:"request_id"` - }{args, requestID} - if err := json.NewEncoder(conn).Encode(command); err != nil { + request := map[string]any{"command": command, "request_id": requestID} + if err := json.NewEncoder(conn).Encode(request); err != nil { return fmt.Errorf("send player IPC command: %w", err) } return nil diff --git a/internal/tui/player/terminal_integration_test.go b/internal/tui/player/terminal_integration_test.go index f44ee66..8e8787e 100644 --- a/internal/tui/player/terminal_integration_test.go +++ b/internal/tui/player/terminal_integration_test.go @@ -49,11 +49,13 @@ func TestTerminalPlayback(t *testing.T) { noReport bool wantTime float64 wantErr bool + auto bool }{ {name: "eof", wantTime: 120}, {name: "quit", wantTime: 31.5}, {name: "trailer", noReport: true}, {name: "startup-failure", wantErr: true}, + {name: "auto-startup-failure", auto: true, wantTime: 120}, {name: "load-failure", wantErr: true}, {name: "stream-failure", wantErr: true}, {name: "controls", wantTime: 120}, @@ -74,6 +76,9 @@ func TestTerminalPlayback(t *testing.T) { reports = append(reports, status) }, } + if tt.auto { + p.preference = config.VideoAuto + } err := p.Run() if (err != nil) != tt.wantErr { t.Fatalf("Run error = %v, want error %v", err, tt.wantErr) @@ -81,6 +86,9 @@ func TestTerminalPlayback(t *testing.T) { if err != nil && strings.Contains(err.Error(), "secret") { t.Fatal("playback error exposed an authentication token") } + if tt.auto && (p.fallback != config.VideoKitty || p.output != config.VideoTCT) { + t.Fatalf("automatic graphics startup did not fall back to text: %+v", p) + } if tt.wantErr { return } @@ -136,7 +144,8 @@ func TestTerminalPlaybackRestoresTUI(t *testing.T) { for _, scenario := range []string{"tui", "startup-failure"} { t.Run(scenario, func(t *testing.T) { fakeTerminalMPV(t, scenario) - pm := &PlayerManager{updates: make(chan tea.Msg, 1)} + pm := newPlayerManager("") + t.Cleanup(pm.Close) m := &terminalTestModel{command: pm.Play("https://plex.invalid/video", "Film", "", true, config.VideoKitty, 0, nil)} var output bytes.Buffer program := tea.NewProgram(m, tea.WithInput(strings.NewReader("")), tea.WithOutput(&output), tea.WithoutSignalHandler()) @@ -173,10 +182,10 @@ func TestTerminalMPVHelper(t *testing.T) { fmt.Println("Available video outputs:\n kitty graphics\n sixel graphics\n tct text") os.Exit(0) } - if scenario == "startup-failure" { + if scenario == "startup-failure" || scenario == "auto-startup-failure" && slices.Contains(os.Args, "--vo=kitty") { os.Exit(3) } - if !slices.Contains(os.Args, "--idle=once") || !slices.Contains(os.Args, "--vo=kitty") || !slices.Contains(os.Args, "--vo-kitty-use-shm=no") { + if !slices.Contains(os.Args, "--idle=once") || (scenario != "auto-startup-failure" && (!slices.Contains(os.Args, "--vo=kitty") || !slices.Contains(os.Args, "--vo-kitty-use-shm=no"))) { os.Exit(10) } if scenario != "tui" && !slices.Contains(os.Args, "--start=12.345") { @@ -208,35 +217,53 @@ func TestTerminalMPVHelper(t *testing.T) { }) decoder := json.NewDecoder(conn) encoder := json.NewEncoder(conn) - for { + loaded, playlist, unpaused, subtitle := false, false, false, scenario == "tui" + for !loaded || !playlist || !unpaused || !subtitle { var request struct { - Command []any `json:"command"` - RequestID int `json:"request_id"` + Command json.RawMessage `json:"command"` + RequestID int `json:"request_id"` } if decoder.Decode(&request) != nil { os.Exit(14) } - if request.Command[0] != "loadfile" { + var named map[string]any + if json.Unmarshal(request.Command, &named) == nil && named["name"] == "loadfile" { + if scenario == "load-failure" { + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "invalid parameter"}) + _, _ = io.Copy(io.Discard, conn) + return + } + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "success"}) + if scenario == "stream-failure" { + _ = encoder.Encode(map[string]any{"event": "end-file", "reason": "error"}) + return + } + _ = encoder.Encode(map[string]any{"event": "file-loaded"}) + loaded = true continue } - if scenario == "load-failure" { - _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "invalid parameter"}) - _, _ = io.Copy(io.Discard, conn) - return - } - _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "success"}) - if scenario == "stream-failure" { - _ = encoder.Encode(map[string]any{"event": "end-file", "reason": "error"}) - return + var command []any + if json.Unmarshal(request.Command, &command) != nil || len(command) == 0 { + os.Exit(14) } - _ = encoder.Encode(map[string]any{"event": "file-loaded"}) - if scenario != "tui" { - if decoder.Decode(&request) != nil || len(request.Command) != 5 || request.Command[0] != "sub-add" || request.Command[3] != "English CC" || request.Command[4] != "eng" { + switch command[0] { + case "get_property": + if len(command) == 2 && command[1] == "playlist" { + _ = encoder.Encode(map[string]any{"request_id": request.RequestID, "error": "success", "data": []any{map[string]any{"id": 1}}}) + playlist = true + } + case "set_property": + if len(command) == 3 && command[1] == "pause" && command[2] == false { + unpaused = true + } + case "sub-add": + if len(command) != 5 || command[3] != "English CC" || command[4] != "eng" { os.Exit(15) } + subtitle = true } - break } + for _, property := range []struct { id int data any diff --git a/internal/tui/player/terminal_queue_integration_test.go b/internal/tui/player/terminal_queue_integration_test.go new file mode 100644 index 0000000..0ff6e16 --- /dev/null +++ b/internal/tui/player/terminal_queue_integration_test.go @@ -0,0 +1,181 @@ +//go:build integration && !windows + +package player + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func summarizeReports(statuses []PlayerStatus) []timelineReport { + reports := make([]timelineReport, 0, len(statuses)) + for _, status := range statuses { + reports = append(reports, timelineReport{key: status.Key, state: string(status.State), + time: int64(status.Time * 1000), duration: int64(status.Duration * 1000)}) + } + return reports +} + +func TestTerminalQueueReportsEveryTrack(t *testing.T) { + isolatePlayerCache(t) + mpv, err := exec.LookPath("mpv") + if err != nil { + t.Skip("mpv is not installed") + } + dir := t.TempDir() + wrapper := filepath.Join(dir, "mpv") + script := "#!/bin/sh\nexec '" + strings.ReplaceAll(mpv, "'", "'\\''") + "' --no-config --ao=null \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + media := queueTestAudio(t, dir) + playback := &terminalPlayback{ + mpv: wrapper, output: config.VideoOutput("null"), stdin: strings.NewReader(""), stdout: io.Discard, + } + for i := range 8 { + playback.items = append(playback.items, PlayItem{ + URL: media, Title: fmt.Sprintf("Track %d", i), RatingKey: fmt.Sprint(i), Duration: .02, + }) + } + setTrack(&playback.status, playback.items[0]) + playback.status.Time = .01 // Resume only the first entry. + reports := []PlayerStatus{} + playback.report = func(_ context.Context, status PlayerStatus) { reports = append(reports, status) } + if err := playback.Run(); err != nil { + t.Fatal(err) + } + completed := make(map[string]bool) + for _, status := range reports { + if status.State != "stopped" { + continue + } + if status.Time != .02 || status.Duration != .02 { + t.Errorf("incorrect track completion: key=%q time=%g duration=%g", status.Key, status.Time, status.Duration) + } + completed[status.Key] = true + } + for _, item := range playback.items { + if !completed[item.RatingKey] { + t.Errorf("missing completion for track %q; reports %+v", item.RatingKey, summarizeReports(reports)) + } + } +} + +func TestTerminalQueueRetainsEventsBeforePlaylistDiscovery(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("PLEXCTL_QUEUE_TEST_BINARY", binary) + t.Setenv("PLEXCTL_QUEUE_HELPER", "early-events") + wrapper := filepath.Join(t.TempDir(), "mpv") + script := "#!/bin/sh\nexec \"$PLEXCTL_QUEUE_TEST_BINARY\" -test.run=^TestTerminalQueueMPVHelper$ -- \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + playback := &terminalPlayback{ + mpv: wrapper, output: config.VideoOutput("null"), stdin: strings.NewReader(""), stdout: io.Discard, + } + for i := range 3 { + playback.items = append(playback.items, PlayItem{URL: "same-file", RatingKey: fmt.Sprint(i), Duration: 1}) + } + setTrack(&playback.status, playback.items[0]) + reports := []PlayerStatus{} + playback.report = func(_ context.Context, status PlayerStatus) { reports = append(reports, status) } + if err := playback.Run(); err != nil { + t.Fatal(err) + } + completed := make(map[string]bool) + for _, status := range reports { + if status.State == "stopped" && status.Time == 1 { + completed[status.Key] = true + } + } + for _, item := range playback.items { + if !completed[item.RatingKey] { + t.Errorf("lost pre-discovery completion for %q; reports %+v", item.RatingKey, summarizeReports(reports)) + } + } +} + +func TestTerminalQueueMPVHelper(t *testing.T) { + if os.Getenv("PLEXCTL_QUEUE_HELPER") != "early-events" { + return + } + var socket string + for _, arg := range os.Args { + if value, ok := strings.CutPrefix(arg, "--input-ipc-server="); ok { + socket = value + } + } + listener, err := net.Listen("unix", socket) + if err != nil { + os.Exit(10) + } + conn, err := listener.Accept() + if err != nil { + os.Exit(11) + } + decoder, encoder := json.NewDecoder(conn), json.NewEncoder(conn) + complete := func(id int64) { + for _, event := range []terminalEvent{ + {Event: "start-file", PlaylistEntryID: id}, + {Event: "file-loaded"}, + {Event: "end-file", PlaylistEntryID: id, Reason: "eof"}, + } { + if encoder.Encode(event) != nil { + os.Exit(12) + } + } + } + loaded := 0 + for { + var request struct { + ID int `json:"request_id"` + Command json.RawMessage `json:"command"` + } + if decoder.Decode(&request) != nil { + os.Exit(13) + } + var command []any + var data any + playlist := json.Unmarshal(request.Command, &command) == nil && + len(command) > 1 && command[0] == "get_property" && command[1] == "playlist" + if playlist { + data = []map[string]int64{{"id": 10}, {"id": 11}, {"id": 12}} + } + if encoder.Encode(map[string]any{"request_id": request.ID, "error": "success", "data": data}) != nil { + os.Exit(14) + } + var named struct { + Name string `json:"name"` + } + if json.Unmarshal(request.Command, &named) == nil && named.Name == "loadfile" { + loaded++ + if loaded <= 2 { + complete(int64(loaded + 9)) + } + } + if playlist { + complete(12) + // Keep IPC open until the caller has issued its initial unpause. + if decoder.Decode(&request) != nil || + encoder.Encode(map[string]any{"request_id": request.ID, "error": "success"}) != nil { + os.Exit(15) + } + _ = conn.Close() + _ = listener.Close() + os.Exit(0) + } + } +} diff --git a/internal/tui/player/video_output.go b/internal/tui/player/video_output.go index fd3e815..2845b59 100644 --- a/internal/tui/player/video_output.go +++ b/internal/tui/player/video_output.go @@ -73,3 +73,24 @@ func selectTerminalOutput(preference config.VideoOutput, help string, getenv fun } return config.VideoWindow, fmt.Errorf("no compatible terminal video output found; check 'mpv --vo=help' and select kitty, sixel, or tct in Settings") } + +// sixelSizeArgs bounds the pixel work without stretching the physical cells. +// Both the pixel size and matching cell geometry must describe the same area. +func sixelSizeArgs(limit, columns, rows, cellW, cellH int) []string { + if limit <= 0 { + return nil + } + if cellW <= 0 || cellH <= 0 { + cellW, cellH = 8, 16 + } + columns, rows = max(1, columns), max(2, rows) + width := min(columns*cellW, limit) + cols := max(1, width/cellW) + return []string{ + fmt.Sprintf("--vo-sixel-width=%d", width), + fmt.Sprintf("--vo-sixel-height=%d", rows*cellH), + fmt.Sprintf("--vo-sixel-cols=%d", cols), + fmt.Sprintf("--vo-sixel-rows=%d", rows), + fmt.Sprintf("--vo-sixel-left=%d", (columns-cols)/2+1), + } +} diff --git a/internal/tui/player/visualizer.go b/internal/tui/player/visualizer.go new file mode 100644 index 0000000..6899b6d --- /dev/null +++ b/internal/tui/player/visualizer.go @@ -0,0 +1,22 @@ +package player + +// musicVideoGraph keeps the audible stream intact while creating a video feed +// for windowed music and explicit terminal video. AudioTerminal keeps its own +// waveform sampler and never loads this graph. +func musicVideoGraph(style string) string { + const size = "1024x576" + filter := "" + switch style { + case "cqt": + filter = "showcqt=s=" + size + ":fps=30" + case "spectrum": + filter = "showspectrum=s=" + size + ":slide=scroll:color=intensity:fps=30" + case "waves": + filter = "showwaves=s=" + size + ":mode=line:rate=30" + case "scope": + filter = "avectorscope=s=" + size + ":rate=30:draw=line" + default: + return "" + } + return "[aid1]asplit[ao][vis];[vis]" + filter + "[vo]" +} diff --git a/internal/tui/player/visualizer_test.go b/internal/tui/player/visualizer_test.go new file mode 100644 index 0000000..1e939c2 --- /dev/null +++ b/internal/tui/player/visualizer_test.go @@ -0,0 +1,39 @@ +package player + +import ( + "slices" + "strings" + "testing" +) + +func TestMusicVideoGraphs(t *testing.T) { + for _, tt := range []struct{ name, filter string }{ + {name: "cqt", filter: "showcqt="}, {name: "spectrum", filter: "showspectrum="}, + {name: "waves", filter: "showwaves="}, {name: "scope", filter: "avectorscope="}, + } { + t.Run(tt.name, func(t *testing.T) { + graph := musicVideoGraph(tt.name) + if !strings.HasPrefix(graph, "[aid1]asplit[ao][vis];") || !strings.Contains(graph, tt.filter) { + t.Fatalf("visualizer changes audible routing or selects wrong graph: %s", graph) + } + }) + } + if musicVideoGraph("off") != "" { + t.Fatal("disabled visualizer still creates video") + } +} + +func TestSixelSizeCapAndCentering(t *testing.T) { + args := sixelSizeArgs(640, 200, 50, 10, 20) + for _, want := range []string{"--vo-sixel-width=640", "--vo-sixel-cols=64", "--vo-sixel-left=69", "--vo-sixel-height=1000"} { + if !slices.Contains(args, want) { + t.Fatalf("missing %s: %v", want, args) + } + } + if len(sixelSizeArgs(0, 200, 50, 10, 20)) != 0 { + t.Fatal("automatic sizing was capped") + } + if args := sixelSizeArgs(640, 40, 25, 8, 16); !slices.Contains(args, "--vo-sixel-width=320") { + t.Fatalf("sizing exceeds a narrow terminal: %v", args) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2974625..56b7eea 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -158,6 +158,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return c, tea.Batch(cmds...) case tea.WindowSizeMsg: + cmds = append(cmds, c.player.ResizeOverlay()) ui.GetLayout().SetPlayerHeight(c.playerHeight(msg.Height)) ui.GetLayout().Update(msg.Width, msg.Height, c.playerStatus.Active()) for i, model := range c.tabManager.tabModels { @@ -169,6 +170,9 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case settings.SettingsFinishedMsg: + if err := artwork.Select(msg.Config.PosterRenderer); err != nil { + return c, func() tea.Msg { return err } + } tints := append([]tint.Tint{ui.PlexctlTheme}, tint.DefaultTints()...) for _, t := range tints { if t.ID() == msg.Config.Theme { @@ -177,7 +181,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } } - return c, tea.Batch(c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth), resizePlayer()) + return c, tea.Batch(c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth), resizePlayer(), tea.ClearScreen) case tuiconfig.ConfigFinishedMsg: cfg := config.Get() @@ -253,6 +257,8 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, tea.Batch(cmds...) case player.PlayerStatusMsg: return c, c.refreshPlayer() + case player.VideoFallbackMsg: + return c, tea.Batch(c.refreshPlayer(), c.alert.NewAlertCmd("info", msg.String())) case tea.KeyMsg: slog.Log(context.Background(), config.LevelTrace, "TUI: key press", "key", msg.String()) @@ -265,7 +271,7 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.navigator.Push(letterjump.New(c.theme)) } case "q", "ctrl+c": - if config.Get().CloseVideoOnQuit || c.playerStatus.AudioOnly { + if config.Get().CloseVideoOnQuit || c.playerStatus.AudioOnly || c.playerStatus.Overlay { return c, c.player.Stop() } return c, tea.Quit @@ -584,6 +590,7 @@ func (c *Controller) showHelp() tea.Cmd { } func (c *Controller) View() string { + c.player.SetOverlayVisible(c.navigator.ActiveOverlay() == nil) base := c.renderBaseView() base = c.navigator.Render(base) return artwork.Frame(c.alert.Render(base)) diff --git a/internal/tui/view/detail/movie_detail.go b/internal/tui/view/detail/movie_detail.go index b85fa95..ca7c352 100644 --- a/internal/tui/view/detail/movie_detail.go +++ b/internal/tui/view/detail/movie_detail.go @@ -80,7 +80,7 @@ func (v *MovieDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (v *MovieDetailView) playTrailer() tea.Cmd { - return player.FetchAndPlayTrailer(v.Metadata, config.VideoWindow, v.Server) + return player.FetchAndPlayTrailer(context.Background(), v.Metadata, config.VideoWindow, v.Server) } func (v *MovieDetailView) hasTrailer() bool { diff --git a/internal/tui/view/detail/music_detail.go b/internal/tui/view/detail/music_detail.go index 6f8c641..82b6af2 100644 --- a/internal/tui/view/detail/music_detail.go +++ b/internal/tui/view/detail/music_detail.go @@ -58,7 +58,7 @@ func (v *MusicDetailView) fetchData() tea.Msg { } var children []components.Metadata if meta.Type == "artist" || meta.Type == "album" { - children, err = plex.GetChildren(ctx, v.RatingKey) + children, err = plex.GetMusicChildren(ctx, meta) if err != nil { return v.dataError(err) } @@ -231,7 +231,7 @@ func (v *MusicDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (v *MusicDetailView) HelpKeys() []ui.HelpKey { return []ui.HelpKey{ {Key: "enter", Desc: "Open Album / Track Details"}, - {Key: "p", Desc: "Play Selected Track (First Track of an Album)"}, + {Key: "p", Desc: "Play Selected Track or Entire Album"}, {Key: "v", Desc: "Choose Terminal Music Player / Separate Window"}, {Key: "↑/↓", Desc: "Select Album / Track"}, {Key: "g then letter", Desc: "Jump to an album or track title"}, diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index a11f0a5..e4ead8d 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -51,7 +51,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata, servers .. result := PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk} if rk != "" { - if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(server.CacheNamespace()+"/"+rk, ui.PosterWidth, ui.PosterHeight); ok { return PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk, View: cached} } } @@ -87,11 +87,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata, servers .. return result } - height := ui.PosterHeight - if metadata.Type == "album" || metadata.Type == "track" { - height = min(height, max(1, (ui.PosterWidth*img.Bounds().Dy()/img.Bounds().Dx()+1)/2)) - } - imgStr, err := artwork.Render(server.CacheNamespace()+"/"+rk, img, ui.PosterWidth, height) + imgStr, err := artwork.Render(server.CacheNamespace()+"/"+rk, img, ui.PosterWidth, ui.PosterHeight) if err != nil { return result } diff --git a/internal/tui/widget/resume/resume.go b/internal/tui/widget/resume/resume.go index 30ac54d..c255c37 100644 --- a/internal/tui/widget/resume/resume.go +++ b/internal/tui/widget/resume/resume.go @@ -1,6 +1,7 @@ package resume import ( + "context" "fmt" "strings" @@ -71,7 +72,7 @@ func (m *ResumeOverlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if resume && m.Metadata != nil && m.Metadata.ViewOffset != nil { offset = int64(*m.Metadata.ViewOffset) } - return nil, player.PlayMedia(m.Metadata, false, m.Output, offset, m.server) + return nil, player.PlayMedia(context.Background(), m.Metadata, false, m.Output, offset, m.server) case "esc": return nil, nil } diff --git a/internal/tui/widget/settings/settings.go b/internal/tui/widget/settings/settings.go index 4abe95b..b7890c4 100644 --- a/internal/tui/widget/settings/settings.go +++ b/internal/tui/widget/settings/settings.go @@ -2,6 +2,7 @@ package settings import ( "fmt" + "strconv" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -78,6 +79,10 @@ func (m *SettingsOverlayModel) updateItems() { settingItem{id: "auto_home_login", title: "Auto Home Login", description: "Login automatically if token exists", current: fmt.Sprintf("%v", cfg.AutoHomeLogin)}, settingItem{id: "close_video_on_quit", title: "Close Video On Quit", description: "Close mpv when exiting app", current: fmt.Sprintf("%v", cfg.CloseVideoOnQuit)}, settingItem{id: "terminal_video", title: "Terminal Video", description: "Video renderer for Ctrl+P", current: string(cfg.TerminalVideo)}, + settingItem{id: "poster_renderer", title: "Poster Renderer", description: "Native image protocol or half-cell text", current: cfg.PosterRenderer}, + settingItem{id: "terminal_video_mode", title: "Terminal Video Mode", description: "Suspend the TUI or overlay video while browsing", current: cfg.TerminalVideoMode}, + settingItem{id: "sixel_video_width", title: "Sixel Video Width", description: "Pixel cap for smoother playback; 0 uses the terminal size", current: strconv.Itoa(cfg.SixelVideoWidth)}, + settingItem{id: "window_music_visualizer", title: "Music Video Visualizer", description: "Music in an mpv window or terminal video", current: cfg.WindowMusicVisualizer}, settingItem{id: "music_player", title: "Music Player", description: "Where music plays", current: cfg.MusicPlayer}, settingItem{id: "music_visualizer", title: "Music Visualizer", description: "Audio-reactive terminal display (z to cycle)", current: cfg.MusicVisualizer}, } @@ -233,6 +238,29 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { selectionItem{id: string(config.VideoSixel), value: "Sixel graphics"}, selectionItem{id: string(config.VideoTCT), value: "TCT (text fallback)"}, } + case "poster_renderer": + m.selectionList.Title = "Choose Poster Renderer" + items = []list.Item{selectionItem{id: "auto", value: "Auto (Kitty, Sixel, half-cell)"}, + selectionItem{id: "kitty", value: "Kitty / Ghostty placeholders"}, + selectionItem{id: "sixel", value: "Sixel graphics"}, selectionItem{id: "halfcell", value: "Half-cell text"}} + case "terminal_video_mode": + m.selectionList.Title = "Choose Terminal Video Mode" + items = []list.Item{selectionItem{id: "suspend", value: "Suspend TUI · full terminal and video controls"}, + selectionItem{id: "overlay", value: "Overlay · keep the TUI and player controls active"}} + case "sixel_video_width": + m.selectionList.Title = "Choose Sixel Pixel Width" + for _, width := range []string{"320", "480", "640", "960", "1280", "0"} { + label := width + " pixels" + if width == "0" { + label = "Automatic (full terminal)" + } + items = append(items, selectionItem{id: width, value: label}) + } + case "window_music_visualizer": + m.selectionList.Title = "Choose Music Video Visualizer" + for _, style := range []string{"cqt", "spectrum", "waves", "scope", "off"} { + items = append(items, selectionItem{id: style, value: style}) + } case "music_player": m.selectionList.Title = "Choose Music Player" items = []list.Item{ @@ -264,6 +292,14 @@ func (m *SettingsOverlayModel) prepareSelection(id string) { current = string(cfg.DefaultViewMode) case "terminal_video": current = string(cfg.TerminalVideo) + case "poster_renderer": + current = cfg.PosterRenderer + case "terminal_video_mode": + current = cfg.TerminalVideoMode + case "sixel_video_width": + current = strconv.Itoa(cfg.SixelVideoWidth) + case "window_music_visualizer": + current = cfg.WindowMusicVisualizer case "music_player": current = cfg.MusicPlayer case "music_visualizer": @@ -291,6 +327,14 @@ func (m *SettingsOverlayModel) applySetting(setting, value string) { cfg.DefaultViewMode = config.ViewMode(value) case "terminal_video": cfg.TerminalVideo = config.VideoOutput(value) + case "poster_renderer": + cfg.PosterRenderer = value + case "terminal_video_mode": + cfg.TerminalVideoMode = value + case "sixel_video_width": + cfg.SixelVideoWidth, _ = strconv.Atoi(value) + case "window_music_visualizer": + cfg.WindowMusicVisualizer = value case "music_player": cfg.MusicPlayer = value case "music_visualizer": From a89357c30800a122369f52adbefe34effd993029 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:36:15 -0400 Subject: [PATCH 19/21] feat: add mpv source builds and optional tmux fullscreen --- README.md | 33 +- docs/content/index.mdx | 17 +- docs/content/tui.mdx | 15 +- internal/mpvsetup/apt.go | 176 ++++++++++ internal/mpvsetup/apt_test.go | 124 +++++++ internal/mpvsetup/check.go | 13 +- internal/mpvsetup/install.go | 25 +- internal/mpvsetup/process_linux.go | 15 + internal/mpvsetup/process_other.go | 7 + internal/mpvsetup/source.go | 321 ++++++++++++++++++ internal/mpvsetup/source_integration_test.go | 273 +++++++++++++++ internal/mpvsetup/source_test.go | 125 +++++++ internal/termgraphics/pane.go | 120 +++++++ internal/termgraphics/pane_test.go | 154 +++++++++ internal/termgraphics/terminal.go | 32 -- internal/termgraphics/writer.go | 21 ++ internal/termgraphics/writer_test.go | 35 ++ internal/tui/player/terminal.go | 110 ++++-- internal/tui/player/terminal_controls.go | 33 +- internal/tui/player/terminal_controls_test.go | 43 ++- 20 files changed, 1615 insertions(+), 77 deletions(-) create mode 100644 internal/mpvsetup/apt.go create mode 100644 internal/mpvsetup/apt_test.go create mode 100644 internal/mpvsetup/process_linux.go create mode 100644 internal/mpvsetup/process_other.go create mode 100644 internal/mpvsetup/source.go create mode 100644 internal/mpvsetup/source_integration_test.go create mode 100644 internal/mpvsetup/source_test.go create mode 100644 internal/termgraphics/pane.go create mode 100644 internal/termgraphics/pane_test.go diff --git a/README.md b/README.md index 6eb74bf..1912391 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A robust Plex CLI and TUI written in Go. `plexctl` uses **mpv** for media playback. Before opening the TUI or playing media, it checks the copy on your PATH against the latest stable mpv release. When mpv is missing or outdated, it offers installation or an update and shows the exact -package-manager commands. Nothing is installed unless you answer **yes** in an +installation commands. Nothing is installed unless you answer **yes** in an interactive terminal. You can decline and keep browsing or use an existing player. Run `plexctl mpv` to check explicitly, or `plexctl mpv install` to approve an @@ -20,14 +20,30 @@ are cached for a day; offline checks do not block an installed player. Developme builds newer than the stable release are not downgraded. Automatic installation supports Homebrew and common Linux package managers. -Distribution repositories can lag upstream; plexctl rechecks the resolved binary -after installation and reports if it is still outdated. Other systems receive -manual installation instructions from [mpv.io](https://mpv.io/installation/). +On Debian/Ubuntu, plexctl checks apt's candidate first. If it is older than the +latest stable release, it offers to compile that release from source using +[mpv's Meson build](https://github.com/mpv-player/mpv#compilation). The dependency +pre-check lists all required packages and identifies which need installing or +upgrading, including **Meson from apt**, FFmpeg development libraries, libass, +Lua, audio/display headers, and **libsixel-dev**. You approve the dependency and +build commands together. An unavailable or outdated libplacebo is built as a +static Meson subproject. Older distributions that cannot supply the required +Meson/FFmpeg development versions receive an actionable pre-check error. + +Source builds enable Sixel and Lua explicitly, verify Kitty/Sixel/text/window +video outputs, and install under `~/.local/opt/plexctl-mpv--`. +Only a successful build is activated via `~/.local/bin/mpv`; an existing file or +symlink is preserved alongside it. Put `~/.local/bin` first on your PATH for future +shells (plexctl prints the command when needed). Build logs and sources remain in +the plexctl cache for troubleshooting. A failed build keeps the previous player. +Other package managers recheck the installed version and report outdated packages; +unsupported systems receive instructions from [mpv.io](https://mpv.io/installation/). ### Installation Instructions - **macOS**: `brew install mpv` -- **Linux**: `sudo apt install mpv` or `sudo pacman -S mpv` +- **Debian/Ubuntu**: `plexctl mpv install` (uses a source build when apt is too old) +- **Other Linux**: Use your package manager, such as `sudo pacman -S mpv` - **Windows**: Download from [mpv.io](https://mpv.io/) ## Installation @@ -102,7 +118,9 @@ playback. **Ctrl+P** starts terminal playback using your saved preference. Kitty video includes a visible control bar: play/pause, a draggable seek bar, skip backward/forward, volume, mute, subtitles, playback speed, and **Return**. Click the controls or use **Tab / Shift+Tab** and **Enter**; **h** hides or shows -the bar. The controls also work through tmux and SSH into a Kitty terminal. +the bar. In tmux, playback stays within the current pane. Press **f** or click +**Fullscreen** to zoom it; press **f** again or click **Restore** to return to +the split layout. The controls also work through tmux and SSH into a Kitty terminal. The player uses mpv's Kitty or Sixel graphics output when detected, with TCT text output as a fallback. Press **Space** to pause, use the arrow keys to seek, and @@ -119,7 +137,8 @@ plexctl play 12345 --terminal-video auto Graphics playback requires a compatible terminal and an mpv build that lists the chosen renderer in `mpv --vo=help`. plexctl probes the attached terminal, including through tmux, and uses text output when graphics are unavailable. In tmux 3.3+, -suspended playback temporarily zooms its pane and restores the layout on exit. +suspended playback preserves the pane size until you toggle fullscreen with **f**. +Playback restores the original zoom state on exit if you used this control. On Linux, a terminal that confirms local shared-memory access uses that faster transport; SSH and other terminals keep using portable graphics packets. diff --git a/docs/content/index.mdx b/docs/content/index.mdx index 5c67e25..6d8fb79 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -54,11 +54,22 @@ go install github.com/ygelfand/plexctl@latest **mpv** is required for media playback. Before starting the TUI or a player, plexctl checks for missing or outdated mpv and offers installation with your approval. You can also run `plexctl mpv` or `plexctl mpv install` explicitly. -Package repositories may lag upstream; plexctl verifies the version afterward. -For manual installation: +On Debian/Ubuntu, an outdated apt candidate triggers an offer to build the latest +stable release from source. The pre-check shows required and missing development +packages, including Meson from apt, FFmpeg, libass, Lua, and libsixel. Dependency +installation and compilation require your approval. A compatible libplacebo is +used from apt or built as a static Meson subproject. The build enables Sixel and +Lua and verifies the video outputs before activation. + +Source builds install under `~/.local/opt/plexctl-mpv--` and activate +through `~/.local/bin/mpv`, preserving the previous file or symlink. Put +`~/.local/bin` first on PATH for future shells; plexctl prints instructions when +needed. Logs and sources stay in the plexctl cache. Other package managers verify +the version after installation. For installation outside this setup flow: - **macOS**: `brew install mpv` -- **Linux**: `sudo apt install mpv` or `sudo pacman -S mpv` +- **Debian/Ubuntu**: `plexctl mpv install` (builds from source when apt is too old) +- **Other Linux**: Use your package manager, such as `sudo pacman -S mpv` - **Windows**: Download from [mpv.io](https://mpv.io/) ### Usage diff --git a/docs/content/tui.mdx b/docs/content/tui.mdx index 6346151..6f57531 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -195,7 +195,7 @@ player bar to pause/resume, and `x` or the footer's **Stop** action to stop. Press **`v`** on a movie or episode to open the terminal playback menu. Select **Kitty graphics**, **Sixel graphics**, **Text**, or **Automatic**. The menu shows the detected terminal capabilities. **`ctrl+p`** uses the preference saved in -Settings. By default playback takes over the full terminal; the library view returns when +Settings. By default playback takes over the current terminal or tmux pane; the library view returns when the video ends or you press **`q`**. Choose **Ctrl+S → Terminal Video Mode → Overlay** to draw video over the content @@ -215,6 +215,12 @@ Press **`h`** to hide or show the controls. In Kitty, **Esc** also returns to th library and **+ / -** adjust volume. Scrolling over the volume controls adjusts volume; scrolling elsewhere seeks. +In tmux, playback starts at the current pane size. Press **`f`** or click the +**Fullscreen** button to zoom the pane. Press **`f`** again or click **Restore** +to return to the split layout. Video and controls resize even while paused. +Stopping playback restores the zoom state from before playback if you used +the fullscreen control; an already zoomed pane stays zoomed when you return. + During suspended playback, these shortcuts are available in every terminal renderer: - **`Space`** or **left click on the picture**: Pause or resume. @@ -222,6 +228,7 @@ During suspended playback, these shortcuts are available in every terminal rende - **`9` / `0`**: Decrease or increase volume. - **`m`**: Mute or unmute. - **`j`**: Cycle subtitle tracks. +- **`f`**: Toggle fullscreen pane zoom in tmux. - **`q`** or **right click**: Stop and return to the library. The resume prompt, external subtitle tracks, and Plex watch progress also work @@ -260,9 +267,9 @@ open a video window. The existing `plexctl play 12345 --tct` explicitly selects Kitty output uses escape-code transport, so it does not require shared memory with the terminal when using SSH. With tmux 3.3+, plexctl temporarily enables -graphics passthrough for its own pane. During suspended graphics playback it zooms that -pane so video cannot cover neighboring panes, then restores the layout and -passthrough setting on exit. Older mpv releases are supported by forwarding +graphics passthrough for its own pane. Suspended playback fits the current pane; +fullscreen pane zoom is optional via **f**, with a matching button for Kitty. +The prior zoom state and passthrough setting are restored on exit. Older mpv releases are supported by forwarding graphics and input through plexctl. GNU screen uses TCT in auto mode. Rendering speed and image quality depend on terminal size, connection speed, and mpv. Installing Kitty alone does not enable graphics in another emulator; diff --git a/internal/mpvsetup/apt.go b/internal/mpvsetup/apt.go new file mode 100644 index 0000000..be460f7 --- /dev/null +++ b/internal/mpvsetup/apt.go @@ -0,0 +1,176 @@ +package mpvsetup + +import ( + "context" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +type queryFunc func(context.Context, string, ...string) ([]byte, error) + +func queryCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + cmd.WaitDelay = time.Second + return cmd.Output() +} + +type aptPackage struct { + installed string + candidate string +} + +func aptPackages(ctx context.Context, query queryFunc, names []string) (map[string]aptPackage, error) { + out, err := query(ctx, "apt-cache", append([]string{"policy"}, names...)...) + if err != nil { + return nil, fmt.Errorf("check apt package versions: %w", err) + } + packages := make(map[string]aptPackage) + var name string + for line := range strings.SplitSeq(string(out), "\n") { + if line != "" && line[0] != ' ' && strings.HasSuffix(line, ":") { + // Multiarch packages may be printed as libfoo-dev:arm64. + name = strings.SplitN(strings.TrimSuffix(line, ":"), ":", 2)[0] + } + fields := strings.Fields(line) + if name == "" || len(fields) != 2 { + continue + } + value := fields[1] + if value == "(none)" { + value = "" + } + p := packages[name] + switch fields[0] { + case "Installed:": + p.installed = value + case "Candidate:": + p.candidate = value + } + packages[name] = p + } + return packages, nil +} + +var aptVersionPattern = regexp.MustCompile(`^(?:[0-9]+:)?([0-9]+)\.([0-9]+)(?:\.([0-9]+))?`) + +// Debian epochs and revisions do not describe the upstream feature version. +// In particular, FFmpeg 7:5.1 is still too old for mpv's FFmpeg >= 6.1 API. +func upstreamAPT(v string) string { + m := aptVersionPattern.FindStringSubmatch(v) + if m == nil { + return "" + } + patch := m[3] + if patch == "" { + patch = "0" + } + result := m[1] + "." + m[2] + "." + patch + if strings.Contains(v, "~") { + result += "-rc" + } + return result +} + +func olderAPT(v, minimum string) bool { + upstream := upstreamAPT(v) + return upstream == "" || older(upstream, minimum) +} + +func availableVersion(v string) string { + if v == "" { + return "(unavailable)" + } + return v +} + +type buildDependency struct { + name string + minimum string +} + +// These cover mpv 0.41's required libraries and plexctl's window/terminal +// playback, audio, subtitles, Lua controls, and common hardware decoding. +// Meson remains the authority on requirements for newer upstream releases. +var mpvBuildDependencies = []buildDependency{ + {"build-essential", ""}, {"git", ""}, {"ca-certificates", ""}, + {"meson", "1.3.0"}, {"ninja-build", ""}, {"pkg-config", ""}, + {"libavcodec-dev", "6.1.0"}, {"libavfilter-dev", "6.1.0"}, + {"libavformat-dev", "6.1.0"}, {"libavutil-dev", "6.1.0"}, + {"libswresample-dev", "6.1.0"}, {"libswscale-dev", "6.1.0"}, + {"libass-dev", "0.12.2"}, {"liblua5.2-dev", ""}, {"libsixel-dev", ""}, + {"libjpeg-dev", ""}, {"libuchardet-dev", ""}, {"zlib1g-dev", ""}, + {"libasound2-dev", ""}, {"libpulse-dev", ""}, + {"libx11-dev", ""}, {"libxrandr-dev", ""}, {"libxext-dev", ""}, + {"libxss-dev", ""}, {"libxpresent-dev", ""}, {"libxv-dev", ""}, + {"libgl-dev", ""}, {"libegl-dev", ""}, {"libdrm-dev", ""}, {"libgbm-dev", ""}, + {"libwayland-dev", ""}, {"wayland-protocols", ""}, {"libxkbcommon-dev", ""}, + {"libva-dev", ""}, {"libvdpau-dev", ""}, +} + +const placeboVersion = "6.338.2" + +var placeboBuildDependencies = []buildDependency{ + {"python3-jinja2", ""}, {"libvulkan-dev", ""}, + {"libshaderc-dev", ""}, {"liblcms2-dev", ""}, +} + +type dependencyPlan struct { + required []buildDependency + missing []string + placebo bool +} + +func (d buildDependency) satisfied(v string) bool { + return v != "" && (d.minimum == "" || !olderAPT(v, d.minimum)) +} + +func sourceDependencies(ctx context.Context, query queryFunc) (dependencyPlan, error) { + deps := append([]buildDependency(nil), mpvBuildDependencies...) + placebo := buildDependency{"libplacebo-dev", placeboVersion} + names := make([]string, 0, len(deps)+len(placeboBuildDependencies)+1) + for _, dep := range deps { + names = append(names, dep.name) + } + names = append(names, placebo.name) + for _, dep := range placeboBuildDependencies { + names = append(names, dep.name) + } + packages, err := aptPackages(ctx, query, names) + if err != nil { + return dependencyPlan{}, err + } + p := packages[placebo.name] + fallback := !placebo.satisfied(p.installed) && !placebo.satisfied(p.candidate) + if fallback { + deps = append(deps, placeboBuildDependencies...) + } else { + deps = append(deps, placebo) + } + plan := dependencyPlan{required: deps, placebo: fallback} + var unavailable []string + for _, dep := range deps { + p := packages[dep.name] + if dep.satisfied(p.installed) { + continue + } + plan.missing = append(plan.missing, dep.name) + if !dep.satisfied(p.candidate) { + requirement := dep.name + if dep.minimum != "" { + requirement += " >= " + dep.minimum + } + unavailable = append(unavailable, requirement+" (apt: "+availableVersion(p.candidate)+")") + } + } + if len(unavailable) > 0 { + return plan, fmt.Errorf("build dependencies unavailable: %s; refresh apt indexes or use a distribution/repository with these development packages, then retry; build instructions: https://github.com/mpv-player/mpv#compilation", strings.Join(unavailable, ", ")) + } + return plan, nil +} diff --git a/internal/mpvsetup/apt_test.go b/internal/mpvsetup/apt_test.go new file mode 100644 index 0000000..e8e3118 --- /dev/null +++ b/internal/mpvsetup/apt_test.go @@ -0,0 +1,124 @@ +package mpvsetup + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" +) + +func TestAPTPackageVersions(t *testing.T) { + query := func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "apt-cache" || !slices.Equal(args, []string{"policy", "mpv", "meson", "libplacebo-dev"}) { + t.Fatalf("unexpected query: %s %v", name, args) + } + return []byte(`mpv: + Installed: 0.37.0-1ubuntu4 + Candidate: 1:0.41.0-2 + Version table: + 1:0.41.0-2 500 + 500 http://example.invalid noble/universe arm64 Packages +meson: + Installed: (none) + Candidate: 1.3.2-1ubuntu1 +libplacebo-dev:arm64: + Installed: (none) + Candidate: (none) +`), nil + } + got, err := aptPackages(t.Context(), query, []string{"mpv", "meson", "libplacebo-dev"}) + if err != nil { + t.Fatal(err) + } + if got["mpv"] != (aptPackage{"0.37.0-1ubuntu4", "1:0.41.0-2"}) || + got["meson"] != (aptPackage{"", "1.3.2-1ubuntu1"}) || got["libplacebo-dev"] != (aptPackage{}) { + t.Fatalf("parsed package versions: %#v", got) + } + _, err = aptPackages(t.Context(), func(context.Context, string, ...string) ([]byte, error) { + return nil, errors.New("query failed") + }, nil) + if err == nil { + t.Fatal("failed apt query must not look like missing packages") + } +} + +func TestOlderAPT(t *testing.T) { + for _, tt := range []struct { + value, minimum string + want bool + }{ + {"0.37.0-1ubuntu4", "0.41.0", true}, + {"1:0.41.0-2", "0.41.0", false}, + {"0.41.0~rc1-1", "0.41.0", true}, + {"0.42.0+git123-1", "0.41.0", false}, + {"7:5.1.6-1", "6.1.0", true}, + {"7:6.1-1", "6.1.0", false}, + {"7:6.1.1-3ubuntu5+esm15", "6.1.0", false}, + {"1.3.2-1ubuntu1", "1.3.0", false}, + {"(none)", "0.41.0", true}, + {"unexpected", "0.41.0", true}, + } { + t.Run(tt.value, func(t *testing.T) { + if got := olderAPT(tt.value, tt.minimum); got != tt.want { + t.Fatalf("olderAPT(%q, %q) = %v", tt.value, tt.minimum, got) + } + }) + } +} + +func dependencyPolicy(packages map[string]aptPackage) string { + var result strings.Builder + for name, p := range packages { + fmt.Fprintf(&result, "%s:\n Installed: %s\n Candidate: %s\n", name, availableVersion(p.installed), availableVersion(p.candidate)) + } + return strings.ReplaceAll(result.String(), "(unavailable)", "(none)") +} + +func availableDependencies() map[string]aptPackage { + packages := make(map[string]aptPackage) + for _, deps := range [][]buildDependency{mpvBuildDependencies, placeboBuildDependencies, {{"libplacebo-dev", placeboVersion}}} { + for _, dep := range deps { + packages[dep.name] = aptPackage{"9.99.99", "9.99.99"} + } + } + return packages +} + +func TestSourceDependencies(t *testing.T) { + for _, tt := range []struct { + name, pkg string + state aptPackage + missing, wantError bool + fallback bool + }{ + {name: "ready"}, + {name: "meson missing", pkg: "meson", state: aptPackage{"", "1.3.2-1"}, missing: true}, + {name: "meson upgrade", pkg: "meson", state: aptPackage{"0.63.0-1", "1.3.2-1"}, missing: true}, + {name: "meson too old", pkg: "meson", state: aptPackage{"", "0.63.0-1"}, missing: true, wantError: true}, + {name: "old ffmpeg epoch", pkg: "libavcodec-dev", state: aptPackage{"7:5.1.6-1", "7:5.1.6-1"}, missing: true, wantError: true}, + {name: "missing sixel", pkg: "libsixel-dev", state: aptPackage{"", "1.10.3-1"}, missing: true}, + {name: "sixel unavailable", pkg: "libsixel-dev", missing: true, wantError: true}, + {name: "placebo upgrade", pkg: "libplacebo-dev", state: aptPackage{"4.208.0-1", "6.338.2-1"}, missing: true}, + {name: "placebo source", pkg: "libplacebo-dev", state: aptPackage{"4.208.0-1", "4.208.0-1"}, fallback: true}, + {name: "placebo absent", pkg: "libplacebo-dev", fallback: true}, + {name: "installed newer than repo", pkg: "libplacebo-dev", state: aptPackage{"7.349.0-1", "4.208.0-1"}}, + } { + t.Run(tt.name, func(t *testing.T) { + packages := availableDependencies() + if tt.pkg != "" { + packages[tt.pkg] = tt.state + } + plan, err := sourceDependencies(t.Context(), func(context.Context, string, ...string) ([]byte, error) { + return []byte(dependencyPolicy(packages)), nil + }) + if (err != nil) != tt.wantError || plan.placebo != tt.fallback || slices.Contains(plan.missing, tt.pkg) != tt.missing { + t.Fatalf("plan=%+v err=%v", plan, err) + } + if err != nil && !strings.Contains(err.Error(), tt.pkg) { + t.Fatalf("preflight error must identify dependency: %v", err) + } + }) + } +} diff --git a/internal/mpvsetup/check.go b/internal/mpvsetup/check.go index b05fae9..2176023 100644 --- a/internal/mpvsetup/check.go +++ b/internal/mpvsetup/check.go @@ -71,6 +71,10 @@ func installed(ctx context.Context) (Status, error) { } return Status{}, fmt.Errorf("find mpv: %w", err) } + return inspect(ctx, path) +} + +func inspect(ctx context.Context, path string) (Status, error) { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() out, err := exec.CommandContext(ctx, path, "--no-config", "--version").Output() @@ -87,6 +91,11 @@ func installed(ctx context.Context) (Status, error) { return Status{Path: path, Installed: strings.TrimPrefix(fields[1], "v")}, nil } +func stableVersion(v string) bool { + _, ok := version(v) + return ok && !strings.ContainsAny(v, "-+") +} + type releaseCache struct { Version string `json:"version"` Checked time.Time `json:"checked"` @@ -109,7 +118,7 @@ func latest(ctx context.Context, client *http.Client, endpoint, cacheDir string, if cacheDir != "" { data, _ := os.ReadFile(path) _ = json.Unmarshal(data, &cached) - if _, ok := version(cached.Version); !ok { + if !stableVersion(cached.Version) { cached = releaseCache{} } } @@ -141,7 +150,7 @@ func latest(ctx context.Context, client *http.Client, endpoint, cacheDir string, return cached.Version, fmt.Errorf("invalid mpv release response") } v := strings.TrimPrefix(release.Tag, "v") - if _, ok := version(v); !ok || strings.ContainsAny(v, "-+") || release.Prerelease || release.Draft { + if !stableVersion(v) || release.Prerelease || release.Draft { return cached.Version, fmt.Errorf("mpv release response did not identify a stable version") } if cacheDir != "" { diff --git a/internal/mpvsetup/install.go b/internal/mpvsetup/install.go index 6bfd66e..adbeb2e 100644 --- a/internal/mpvsetup/install.go +++ b/internal/mpvsetup/install.go @@ -18,11 +18,13 @@ const installHelp = "https://mpv.io/installation/" // Plan contains only fixed package-manager arguments, never release metadata. type Plan struct { Commands [][]string + apt bool } func installPlan(goos string, lookup func(string) (string, error), missing, root bool) (Plan, error) { has := func(name string) bool { _, err := lookup(name); return err == nil } var commands [][]string + apt := false privileged := goos != "windows" switch { case (goos == "darwin" || goos == "linux") && has("brew"): @@ -33,6 +35,7 @@ func installPlan(goos string, lookup func(string) (string, error), missing, root } commands = [][]string{{"brew", "update"}, {"brew", action, "mpv"}} case goos == "linux" && has("apt-get"): + apt = true commands = [][]string{{"apt-get", "update"}, {"apt-get", "install", "--yes", "mpv"}} case goos == "linux" && has("dnf"): commands = [][]string{{"dnf", "install", "--refresh", "--assumeyes", "mpv"}} @@ -54,7 +57,7 @@ func installPlan(goos string, lookup func(string) (string, error), missing, root commands[i] = append([]string{"sudo"}, commands[i]...) } } - return Plan{Commands: commands}, nil + return Plan{Commands: commands, apt: apt}, nil } type Options struct { @@ -132,6 +135,18 @@ func Ensure(ctx context.Context, opts Options) error { _, _ = fmt.Fprintln(opts.Error, "mpv update available; run 'plexctl mpv install' to approve an update.") return nil } + // Inspect apt before offering a package known to be too old. This also lets + // users with all build dependencies installed build without needing sudo. + _, aptErr := exec.LookPath("apt-get") + _, brewErr := exec.LookPath("brew") + if runtime.GOOS == "linux" && aptErr == nil && brewErr != nil && s.Latest != "" { + packages, queryErr := aptPackages(ctx, queryCommand, []string{"mpv"}) + candidate := packages["mpv"].candidate + if queryErr == nil && (candidate == "" || olderAPT(candidate, s.Latest)) { + _, _ = fmt.Fprintf(opts.Output, "Apt offers mpv %s; building stable mpv %s from source instead.\n", availableVersion(candidate), s.Latest) + return dependencyError(opts, offerSourceBuild(ctx, s, opts)) + } + } plan, err := installPlan(runtime.GOOS, exec.LookPath, missing, os.Geteuid() == 0) if err != nil { return dependencyError(opts, err) @@ -168,12 +183,20 @@ func Ensure(ctx context.Context, opts Options) error { } _, _ = fmt.Fprintf(opts.Output, "Using mpv %s (%s).\n", after.Installed, after.Path) if s.Latest != "" && older(after.Installed, s.Latest) { + if plan.apt { + _, _ = fmt.Fprintln(opts.Output, "The apt installation is still outdated; a source build is available.") + after.Latest = s.Latest + return dependencyError(opts, offerSourceBuild(ctx, after, opts)) + } return dependencyError(opts, fmt.Errorf("mpv %s is still older than %s; your repositories may be behind or another mpv may take precedence on PATH; see %s", after.Installed, s.Latest, installHelp)) } return nil } func dependencyError(opts Options, err error) error { + if err == nil { + return nil + } if opts.Required { return err } diff --git a/internal/mpvsetup/process_linux.go b/internal/mpvsetup/process_linux.go new file mode 100644 index 0000000..56405d6 --- /dev/null +++ b/internal/mpvsetup/process_linux.go @@ -0,0 +1,15 @@ +package mpvsetup + +import ( + "os/exec" + "syscall" + "time" +) + +func prepareBuildCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Meson spawns Ninja and compilers; cancel the entire build group so they + // cannot keep writing into a prefix that is being cleaned up. + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = time.Second +} diff --git a/internal/mpvsetup/process_other.go b/internal/mpvsetup/process_other.go new file mode 100644 index 0000000..133e5a6 --- /dev/null +++ b/internal/mpvsetup/process_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package mpvsetup + +import "os/exec" + +func prepareBuildCommand(_ *exec.Cmd) {} diff --git a/internal/mpvsetup/source.go b/internal/mpvsetup/source.go new file mode 100644 index 0000000..5e7f23a --- /dev/null +++ b/internal/mpvsetup/source.go @@ -0,0 +1,321 @@ +package mpvsetup + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const mpvRepository = "https://github.com/mpv-player/mpv.git" +const placeboRepository = "https://code.videolan.org/videolan/libplacebo.git" + +type sourcePlan struct { + version, work, prefix, link, backup string + deps dependencyPlan + packages Plan + commands [][]string +} + +func newSourcePlan(v, home, cacheDir string, deps dependencyPlan, root bool, lookup func(string) (string, error)) (sourcePlan, error) { + if !stableVersion(v) { + return sourcePlan{}, fmt.Errorf("a verified stable mpv release is required for a source build") + } + v = strings.TrimPrefix(v, "v") + if !filepath.IsAbs(home) || !filepath.IsAbs(cacheDir) { + return sourcePlan{}, fmt.Errorf("source build requires absolute home and cache directories") + } + id := v + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) + p := sourcePlan{ + version: v, deps: deps, + work: filepath.Join(cacheDir, "mpv-build-"+id), + prefix: filepath.Join(home, ".local", "opt", "plexctl-mpv-"+id), + link: filepath.Join(home, ".local", "bin", "mpv"), + } + p.backup = p.link + ".before-plexctl-" + id + if len(deps.missing) > 0 { + p.packages.Commands = [][]string{ + {"apt-get", "update"}, + append([]string{"apt-get", "install", "--yes", "--no-remove"}, deps.missing...), + } + if !root { + if _, err := lookup("sudo"); err != nil { + return sourcePlan{}, fmt.Errorf("installing missing build dependencies requires sudo or administrator access") + } + for i := range p.packages.Commands { + p.packages.Commands[i] = append([]string{"sudo"}, p.packages.Commands[i]...) + } + } + } + source := filepath.Join(p.work, "mpv") + build := filepath.Join(source, "build") + p.commands = [][]string{{"git", "clone", "--depth=1", "--branch=v" + v, mpvRepository, source}} + if deps.placebo { + p.commands = append(p.commands, []string{ + "git", "clone", "--depth=1", "--recursive", "--branch=v" + placeboVersion, + placeboRepository, filepath.Join(source, "subprojects", "libplacebo"), + }) + } + setup := []string{"meson", "setup", build, source, "--prefix=" + p.prefix, + "--buildtype=release", "--wrap-mode=nodownload", "-Dlibmpv=false", + "-Dlua=enabled", "-Dsixel=enabled", "-Dalsa=enabled", "-Dpulse=enabled", + "-Dx11=enabled", "-Dgl=enabled", "-Degl=enabled", "-Djpeg=enabled", + "-Duchardet=enabled", "-Dzlib=enabled", "-Dmanpage-build=disabled"} + if deps.placebo { + setup = append(setup, "--force-fallback-for=libplacebo", "-Dlibplacebo:default_library=static") + } + p.commands = append(p.commands, setup, + []string{"meson", "compile", "-C", build, "-j", strconv.Itoa(min(4, runtime.NumCPU()))}, + []string{"meson", "install", "-C", build, "--no-rebuild"}) + return p, nil +} + +func describeDependencies(out io.Writer, deps dependencyPlan) { + _, _ = fmt.Fprintln(out, "Build dependencies (Meson is installed through apt):") + for _, dep := range deps.required { + requirement := dep.name + if dep.minimum != "" { + requirement += " >= " + dep.minimum + } + _, _ = fmt.Fprintln(out, " "+requirement) + } + if len(deps.missing) == 0 { + _, _ = fmt.Fprintln(out, "All required apt packages are already installed.") + } else { + _, _ = fmt.Fprintf(out, "Packages to install or upgrade: %s\n", strings.Join(deps.missing, " ")) + } + if deps.placebo { + _, _ = fmt.Fprintf(out, "Apt cannot provide a suitable libplacebo; build v%s as a static Meson subproject.\n", placeboVersion) + } +} + +func offerSourceBuild(ctx context.Context, s Status, opts Options) error { + deps, err := sourceDependencies(ctx, queryCommand) + if len(deps.required) > 0 { + describeDependencies(opts.Output, deps) + } + if err != nil { + return err + } + home, err := os.UserHomeDir() + if err != nil { + return err + } + cacheDir := opts.CacheDir + if cacheDir == "" { + cacheDir, err = os.UserCacheDir() + if err != nil { + return err + } + cacheDir = filepath.Join(cacheDir, "plexctl") + } + cacheDir, err = filepath.Abs(cacheDir) + if err != nil { + return err + } + p, err := newSourcePlan(s.Latest, home, cacheDir, deps, os.Geteuid() == 0, exec.LookPath) + if err != nil { + return err + } + var description strings.Builder + fmt.Fprintf(&description, "Build stable mpv %s with Kitty, Sixel, text video, Lua, subtitles, and audio support:\n", p.version) + for _, commands := range [][][]string{p.packages.Commands, p.commands} { + for _, args := range commands { + fmt.Fprintf(&description, " %s\n", displayCommand(args)) + } + } + fmt.Fprintf(&description, "After verification, activate %s via %s.\n", filepath.Join(p.prefix, "bin", "mpv"), p.link) + fmt.Fprintf(&description, "If %s exists, preserve it at %s.\n", p.link, p.backup) + fmt.Fprintf(&description, "Build sources and logs: %s\nDownloading and compiling may take several minutes.\n", p.work) + if _, err := io.WriteString(opts.Output, description.String()); err != nil { + return fmt.Errorf("show mpv source build: %w", err) + } + approved, err := confirm(opts.Input, opts.Output) + if err != nil { + return err + } + if !approved { + if s.Path == "" { + return fmt.Errorf("mpv source build declined; playback requires mpv") + } + _, _ = fmt.Fprintln(opts.Output, "mpv source build declined; using the installed version.") + return nil + } + return runSourceBuild(ctx, p, opts) +} + +// displayCommand quotes arguments for review only. Execution always uses argv, +// never a shell, including when a home directory contains shell metacharacters. +func displayCommand(args []string) string { + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'" + } + return strings.Join(quoted, " ") +} + +func runSourceBuild(ctx context.Context, p sourcePlan, opts Options) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + if err := runPlan(ctx, p.packages, opts); err != nil { + return err + } + // Recheck after apt runs, before a potentially lengthy download and build. + names := make([]string, 0, len(p.deps.required)) + for _, dep := range p.deps.required { + names = append(names, dep.name) + } + packages, err := aptPackages(ctx, queryCommand, names) + if err != nil { + return err + } + for _, dep := range p.deps.required { + if !dep.satisfied(packages[dep.name].installed) { + return fmt.Errorf("build dependency %s is still missing or too old after apt installation", dep.name) + } + } + meson, err := queryCommand(ctx, "meson", "--version") + if err != nil || olderAPT(strings.TrimSpace(string(meson)), "1.3.0") { + return fmt.Errorf("meson >= 1.3.0 must be on PATH; check that another meson is not shadowing the apt installation") + } + if err := os.MkdirAll(filepath.Dir(p.work), 0o755); err != nil { + return err + } + if err := os.Mkdir(p.work, 0o700); err != nil { + return err + } + logPath := filepath.Join(p.work, "build.log") + log, err := os.OpenFile(logPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer func() { _ = log.Close() }() + // This unique prefix cannot overwrite an existing installation. Leave the + // sources/logs for diagnosis if compilation, verification, or activation fails. + if err := os.MkdirAll(filepath.Dir(p.prefix), 0o755); err != nil { + return err + } + if err := os.Mkdir(p.prefix, 0o755); err != nil { + return err + } + activated := false + defer func() { + if !activated { + _ = os.RemoveAll(p.prefix) + } + }() + for _, args := range p.commands { + _, _ = fmt.Fprintln(log, displayCommand(args)) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = p.work + cmd.Stdin = opts.Input + var outputMu sync.Mutex + cmd.Stdout = &buildOutput{mu: &outputMu, writer: io.MultiWriter(opts.Output, log)} + cmd.Stderr = &buildOutput{mu: &outputMu, writer: io.MultiWriter(opts.Error, log)} + prepareBuildCommand(cmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("mpv source build failed at %s: %w; see %s and mpv/build/meson-logs in %s", args[0], err, logPath, p.work) + } + } + binary := filepath.Join(p.prefix, "bin", "mpv") + if err := verifyBuild(ctx, binary, p.version); err != nil { + return fmt.Errorf("%w; build log: %s", err, logPath) + } + backedUp, err := activateBuild(binary, p.link, p.backup) + if err != nil { + return fmt.Errorf("activate built mpv: %w", err) + } + activated = true + if backedUp { + _, _ = fmt.Fprintf(opts.Output, "Previous mpv preserved at %s.\n", p.backup) + } + // Make the approved player available to playback in this process even when + // ~/.local/bin was absent from PATH. Never modify shell configuration files. + binDir := filepath.Dir(p.link) + resolved, _ := exec.LookPath("mpv") + if resolved != p.link { + if err := os.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { + return err + } + _, _ = fmt.Fprintf(opts.Output, "For future shells, put this directory first on PATH:\n export PATH=%s:\"$PATH\"\n", displayCommand([]string{binDir})) + } + _, _ = fmt.Fprintf(opts.Output, "Using source-built mpv %s (%s). Build log: %s\n", p.version, p.link, logPath) + return nil +} + +// os/exec copies stdout and stderr concurrently. Serialize both destinations +// because callers may use the same non-thread-safe writer for their output. +type buildOutput struct { + mu *sync.Mutex + writer io.Writer +} + +func (w *buildOutput) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.writer.Write(data) +} + +func verifyBuild(ctx context.Context, binary, minimum string) error { + s, err := inspect(ctx, binary) + if err != nil { + return err + } + if older(s.Installed, minimum) { + return fmt.Errorf("built mpv %s is older than requested %s", s.Installed, minimum) + } + out, err := queryCommand(ctx, binary, "--no-config", "--vo=help") + if err != nil { + return fmt.Errorf("check built mpv video outputs: %w", err) + } + outputs := make(map[string]bool) + for line := range strings.SplitSeq(string(out), "\n") { + if fields := strings.Fields(line); len(fields) > 0 { + outputs[fields[0]] = true + } + } + for _, name := range []string{"kitty", "sixel", "tct", "gpu"} { + if !outputs[name] { + return fmt.Errorf("built mpv lacks required %s video output", name) + } + } + return nil +} + +func activateBuild(binary, link, backup string) (bool, error) { + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + return false, err + } + temporary := backup + ".new" + if err := os.Symlink(binary, temporary); err != nil { + return false, err + } + defer func() { _ = os.Remove(temporary) }() + backedUp := false + if info, err := os.Lstat(link); err == nil { + if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + return false, fmt.Errorf("%s is not a file or symlink", link) + } + // A hard link preserves either the previous regular file or symlink, + // while rename below atomically switches the active path on Linux. + if err := os.Link(link, backup); err != nil { + return false, fmt.Errorf("preserve previous mpv: %w", err) + } + backedUp = true + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + if err := os.Rename(temporary, link); err != nil { + return backedUp, err + } + return backedUp, nil +} diff --git a/internal/mpvsetup/source_integration_test.go b/internal/mpvsetup/source_integration_test.go new file mode 100644 index 0000000..0132380 --- /dev/null +++ b/internal/mpvsetup/source_integration_test.go @@ -0,0 +1,273 @@ +//go:build integration && linux + +package mpvsetup + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func fakeSourceTools(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("PATH", dir) + t.Setenv("MPV_TEST_DIR", dir) + t.Setenv("MPV_TEST_FAILURE", "") + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o700); err != nil { + t.Fatal(err) + } + } + packages := availableDependencies() + packages["mpv"] = aptPackage{"0.37.0-1ubuntu4", "0.37.0-1ubuntu4"} + write("policy-after", dependencyPolicy(packages)) + packages["meson"] = aptPackage{"", "1.3.2-1ubuntu1"} + packages["libsixel-dev"] = aptPackage{"", "1.10.3-1"} + write("policy", dependencyPolicy(packages)) + write("apt-cache", "#!/bin/sh\nexec /bin/cat \"$MPV_TEST_DIR/policy\"\n") + write("sudo", "#!/bin/sh\nexec \"$@\"\n") + write("apt-get", `#!/bin/sh +printf 'apt-get %s\n' "$*" >> "$MPV_TEST_DIR/commands" +if [ "$MPV_TEST_FAILURE" = apt ]; then exit 7; fi +if [ "$1" = install ]; then + /bin/cp "$MPV_TEST_DIR/policy-after" "$MPV_TEST_DIR/policy" + for argument; do + if [ "$argument" = mpv ]; then /bin/cp "$MPV_TEST_DIR/built-player" "$MPV_TEST_DIR/mpv"; fi + done +fi +`) + write("git", `#!/bin/sh +printf 'git %s\n' "$*" >> "$MPV_TEST_DIR/commands" +if [ "$MPV_TEST_FAILURE" = clone ]; then exit 8; fi +for destination; do :; done +/bin/mkdir -p "$destination/subprojects" +`) + write("meson", `#!/bin/sh +if [ "$1" = --version ]; then printf '1.3.2\n'; exit 0; fi +printf 'meson %s\n' "$*" >> "$MPV_TEST_DIR/commands" +case "$1" in + setup) + for argument; do + case "$argument" in --prefix=*) printf '%s' "${argument#--prefix=}" > "$MPV_TEST_DIR/prefix";; esac + done + ;; + compile) + if [ "$MPV_TEST_FAILURE" = compile ]; then printf 'compiler failed\n' >&2; exit 9; fi + if [ "$MPV_TEST_FAILURE" = cancel ]; then + /bin/sleep 30 & + printf '%s' "$!" > "$MPV_TEST_DIR/child-pid" + wait + fi + ;; + install) + if [ "$MPV_TEST_FAILURE" = install ]; then exit 10; fi + prefix=$(/bin/cat "$MPV_TEST_DIR/prefix") + /bin/mkdir -p "$prefix/bin" + /bin/cp "$MPV_TEST_DIR/built-player" "$prefix/bin/mpv" + /bin/chmod 700 "$prefix/bin/mpv" + ;; +esac +`) + write("built-player", `#!/bin/sh +case "$*" in + '--no-config --version') + if [ "$MPV_TEST_FAILURE" = version ]; then printf 'mpv 0.37.0\n'; else printf 'mpv 0.41.0\n'; fi + ;; + '--no-config --vo=help') + printf 'Available video outputs:\n kitty Kitty\n tct Text\n gpu OpenGL\n' + if [ "$MPV_TEST_FAILURE" != sixel ]; then printf ' sixel Sixel\n'; fi + ;; +esac +`) + write("mpv", "#!/bin/sh\nprintf 'mpv 0.37.0\\n'\n") + cache, err := json.Marshal(releaseCache{Version: "0.41.0", Checked: time.Now()}) + if err != nil { + t.Fatal(err) + } + write("mpv-release.json", string(cache)) + return dir +} + +func TestEnsureAptSourceApproval(t *testing.T) { + for _, piped := range []bool{false, true} { + t.Run(map[bool]string{false: "declined", true: "piped yes"}[piped], func(t *testing.T) { + dir := fakeSourceTools(t) + var out bytes.Buffer + opts := Options{CacheDir: dir, Output: &out, Error: &out, Required: true, Install: true} + if piped { + opts.Input = strings.NewReader("yes\n") + } else { + opts.Input = approvalTerminal(t, "n\n") + } + if err := Ensure(t.Context(), opts); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "commands")); !os.IsNotExist(err) { + t.Fatal("declined/piped input executed an installation command") + } + if !piped { + for _, want := range []string{"Apt offers mpv 0.37.0", "source", "meson >= 1.3.0", "libsixel-dev", "Proceed with these commands? [y/N]"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("missing %q in preflight:\n%s", want, out.String()) + } + } + } + }) + } +} + +func TestEnsureAptCandidateAndPostInstallFallback(t *testing.T) { + for _, stale := range []bool{false, true} { + t.Run(map[bool]string{false: "current candidate", true: "stale candidate"}[stale], func(t *testing.T) { + dir := fakeSourceTools(t) + packages := availableDependencies() + packages["mpv"] = aptPackage{"0.37.0-1ubuntu4", "0.41.0-1"} + if err := os.WriteFile(filepath.Join(dir, "policy"), []byte(dependencyPolicy(packages)), 0o600); err != nil { + t.Fatal(err) + } + answer := "yes\n" + if stale { + // The repository or PATH did not supply the advertised version. + // Approving apt does not implicitly approve a subsequent build. + t.Setenv("MPV_TEST_FAILURE", "version") + answer += "n\n" + } + var out bytes.Buffer + err := Ensure(t.Context(), Options{CacheDir: dir, Input: approvalTerminal(t, answer), Output: &out, Error: &out, Required: true, Install: true}) + if err != nil { + t.Fatalf("Ensure: %v\n%s", err, out.String()) + } + commands, _ := os.ReadFile(filepath.Join(dir, "commands")) + if !strings.Contains(string(commands), "apt-get install --yes mpv") || strings.Contains(string(commands), "git clone") { + t.Fatalf("unexpected installations: %s", commands) + } + prompts := strings.Count(out.String(), "Proceed with these commands?") + if stale && (prompts != 2 || !strings.Contains(out.String(), "source build declined")) { + t.Fatalf("stale repository must offer a separately approved build:\n%s", out.String()) + } + if !stale && (prompts != 1 || strings.Contains(out.String(), "Build stable mpv")) { + t.Fatalf("current apt candidate should use only the package manager:\n%s", out.String()) + } + }) + } +} + +func TestRunSourceBuild(t *testing.T) { + for _, failure := range []string{"", "apt", "clone", "compile", "install", "version", "sixel"} { + t.Run("failure="+failure, func(t *testing.T) { + dir := fakeSourceTools(t) + t.Setenv("MPV_TEST_FAILURE", failure) + deps, err := sourceDependencies(t.Context(), queryCommand) + if err != nil { + t.Fatal(err) + } + p, err := newSourcePlan("0.41.0", filepath.Join(dir, "user's files $(literal)"), dir, deps, false, exec.LookPath) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(p.link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p.link, []byte("previous player"), 0o700); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + opts := Options{Input: strings.NewReader(""), Output: &out, Error: &out} + err = runSourceBuild(t.Context(), p, opts) + if failure != "" { + if err == nil { + t.Fatalf("%s failure was accepted", failure) + } + old, _ := os.ReadFile(p.link) + if string(old) != "previous player" { + t.Fatal("failed build replaced previous player") + } + if _, err := os.Stat(p.prefix); !os.IsNotExist(err) { + t.Fatal("incomplete install prefix was not removed") + } + if failure == "compile" { + log, _ := os.ReadFile(filepath.Join(p.work, "build.log")) + if !strings.Contains(string(log), "compiler failed") { + t.Fatal("failure log was not preserved") + } + } + return + } + if err != nil { + t.Fatalf("build: %v\n%s", err, out.String()) + } + if old, _ := os.ReadFile(p.backup); string(old) != "previous player" { + t.Fatal("successful installation lost previous player") + } + s, err := installed(t.Context()) + if err != nil || s.Installed != "0.41.0" || s.Path != p.link { + t.Fatalf("new player must be active for immediate playback: %+v, %v", s, err) + } + commands, _ := os.ReadFile(filepath.Join(dir, "commands")) + for _, want := range []string{"apt-get install --yes --no-remove meson libsixel-dev", "--branch=v0.41.0", "meson setup", "meson compile", "meson install"} { + if !strings.Contains(string(commands), want) { + t.Fatalf("missing build step %q in %s", want, commands) + } + } + }) + } +} + +func TestSourceBuildCancellationStopsCompilers(t *testing.T) { + dir := fakeSourceTools(t) + t.Setenv("MPV_TEST_FAILURE", "cancel") + deps, err := sourceDependencies(t.Context(), queryCommand) + if err != nil { + t.Fatal(err) + } + p, err := newSourcePlan("0.41.0", dir, dir, deps, true, exec.LookPath) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + // Use files for output: stdout/stderr may be copied concurrently by os/exec. + log, err := os.CreateTemp(dir, "output") + if err != nil { + t.Fatal(err) + } + defer func() { _ = log.Close() }() + done := make(chan error, 1) + go func() { done <- runSourceBuild(ctx, p, Options{Input: strings.NewReader(""), Output: log, Error: log}) }() + var pid []byte + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + pid, _ = os.ReadFile(filepath.Join(dir, "child-pid")) + if len(pid) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("canceled build succeeded") + } + case <-time.After(3 * time.Second): + t.Fatal("build ignored cancellation") + } + if len(pid) == 0 { + t.Fatal("fake compiler never started") + } + status, err := os.ReadFile(filepath.Join("/proc", string(pid), "status")) + if err == nil && !strings.Contains(string(status), "State:\tZ") { + t.Fatalf("compiler survived cancellation:\n%s", status) + } + if _, err := os.Lstat(p.link); !os.IsNotExist(err) { + t.Fatal("canceled build activated a player") + } +} diff --git a/internal/mpvsetup/source_test.go b/internal/mpvsetup/source_test.go new file mode 100644 index 0000000..035fa1d --- /dev/null +++ b/internal/mpvsetup/source_test.go @@ -0,0 +1,125 @@ +package mpvsetup + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +func TestSourcePlan(t *testing.T) { + root := t.TempDir() + missing := func(string) (string, error) { return "", exec.ErrNotFound } + deps := dependencyPlan{missing: []string{"meson", "libsixel-dev"}, placebo: true} + if _, err := newSourcePlan("0.41.0", root, root, deps, false, missing); err == nil { + t.Fatal("dependency installation requires privilege") + } + p, err := newSourcePlan("0.41.0", root, root, deps, false, func(string) (string, error) { return "/bin/sudo", nil }) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(p.packages.Commands[1], []string{"sudo", "apt-get", "install", "--yes", "--no-remove", "meson", "libsixel-dev"}) { + t.Fatalf("unexpected dependency changes: %v", p.packages.Commands) + } + for _, cmd := range p.commands { + if cmd[0] == "sudo" || cmd[0] == "sh" { + t.Fatalf("build commands must run as the user without a shell: %v", cmd) + } + } + if !slices.Contains(p.commands[0], "--branch=v0.41.0") || !slices.Contains(p.commands[0], mpvRepository) { + t.Fatalf("build must select the stable upstream release: %v", p.commands[0]) + } + if !slices.Contains(p.commands[1], "--branch=v"+placeboVersion) || !slices.Contains(p.commands[1], "--recursive") { + t.Fatalf("libplacebo must use a compatible recursive checkout: %v", p.commands[1]) + } + for _, flag := range []string{"-Dsixel=enabled", "-Dlua=enabled", "-Dalsa=enabled", "-Dpulse=enabled", "--wrap-mode=nodownload"} { + if !slices.Contains(p.commands[2], flag) { + t.Fatalf("missing required build feature %s", flag) + } + } + ready, err := newSourcePlan("0.41.0", root, root, dependencyPlan{}, false, missing) + if err != nil || len(ready.packages.Commands) != 0 { + t.Fatalf("ready build must not need sudo: %v, %v", ready, err) + } + for _, bad := range []string{"", "../0.41.0", "0.41.0;touch /tmp/sentinel", "0.41.0-rc1", "0.41.0+git"} { + if _, err := newSourcePlan(bad, root, root, dependencyPlan{}, true, missing); err == nil { + t.Fatalf("accepted invalid stable release %q", bad) + } + } +} + +func TestActivateBuildPreservesPlayer(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("source installation is only available on apt-based Linux") + } + for _, kind := range []string{"absent", "file", "symlink", "directory", "existing backup"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + link, backup, binary := filepath.Join(dir, "mpv"), filepath.Join(dir, "previous"), filepath.Join(dir, "built") + if err := os.WriteFile(binary, []byte("new player"), 0o700); err != nil { + t.Fatal(err) + } + switch kind { + case "file", "existing backup": + if err := os.WriteFile(link, []byte("old player"), 0o700); err != nil { + t.Fatal(err) + } + case "symlink": + if err := os.Symlink("original-player", link); err != nil { + t.Fatal(err) + } + case "directory": + if err := os.Mkdir(link, 0o700); err != nil { + t.Fatal(err) + } + } + if kind == "existing backup" { + if err := os.WriteFile(backup, []byte("previous backup"), 0o600); err != nil { + t.Fatal(err) + } + } + backedUp, err := activateBuild(binary, link, backup) + if kind == "directory" || kind == "existing backup" { + if err == nil { + t.Fatal("unexpected replacement of existing directory/backup") + } + if kind == "existing backup" { + old, _ := os.ReadFile(link) + if string(old) != "old player" { + t.Fatal("failed activation changed the previous player") + } + } + return + } + if err != nil || backedUp != (kind != "absent") { + t.Fatalf("activation: backup=%v err=%v", backedUp, err) + } + if target, err := os.Readlink(link); err != nil || target != binary { + t.Fatalf("active player=%q, %v", target, err) + } + switch kind { + case "symlink": + if target, err := os.Readlink(backup); err != nil || target != "original-player" { + t.Fatalf("previous symlink not preserved: %q, %v", target, err) + } + case "file": + if old, _ := os.ReadFile(backup); string(old) != "old player" { + t.Fatalf("previous executable not preserved: %q", old) + } + } + if entries, _ := filepath.Glob(backup + ".new"); len(entries) != 0 { + t.Fatal("temporary activation symlink left behind") + } + }) + } +} + +func TestDisplayCommandQuotesPaths(t *testing.T) { + got := displayCommand([]string{"meson", "setup", "/tmp/Phil's files/$(touch sentinel)"}) + if !strings.Contains(got, "'\"'\"'") || !strings.Contains(got, "$(touch sentinel)'") { + t.Fatalf("path must be safely quoted for review: %s", got) + } +} diff --git a/internal/termgraphics/pane.go b/internal/termgraphics/pane.go new file mode 100644 index 0000000..7d1839e --- /dev/null +++ b/internal/termgraphics/pane.go @@ -0,0 +1,120 @@ +package termgraphics + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// PaneZoom tracks playback's explicit fullscreen changes. Creating it only +// reads the current layout; playback starts at the pane's existing size. +// Its methods belong to the playback event loop, not concurrent callers. +type PaneZoom struct { + pane string + run func(...string) ([]byte, error) + initial bool + changed bool +} + +// NewPaneZoom returns nil outside an actual tmux pane. +func NewPaneZoom() (*PaneZoom, error) { + if !InTmux() { + return nil, nil + } + return newPaneZoom(os.Getenv("TMUX_PANE"), func(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + return exec.CommandContext(ctx, "tmux", args...).Output() + }) +} + +func newPaneZoom(pane string, run func(...string) ([]byte, error)) (*PaneZoom, error) { + p := &PaneZoom{pane: pane, run: run} + initial, err := p.Zoomed() + if err != nil { + return nil, err + } + p.initial = initial + return p, nil +} + +func (p *PaneZoom) Zoomed() (bool, error) { + value, err := p.run("display-message", "-p", "-t", p.pane, "#{window_zoomed_flag}") + if err != nil { + return false, fmt.Errorf("inspect tmux pane zoom: %w", err) + } + switch strings.TrimSpace(string(value)) { + case "0": + return false, nil + case "1": + return true, nil + default: + return false, fmt.Errorf("tmux did not report the playback pane's zoom state") + } +} + +// Origin is the pane's position in the attached terminal, including a status +// bar above the window. tmux does not position the cursor for raw passthrough. +func (p *PaneZoom) Origin() (int, int, error) { + value, err := p.run("display-message", "-p", "-t", p.pane, "#{pane_left} #{pane_top} #{status-position} #{status}") + if err != nil { + return 0, 0, fmt.Errorf("inspect tmux pane position: %w", err) + } + fields := strings.Fields(string(value)) + if len(fields) != 4 { + return 0, 0, fmt.Errorf("tmux did not report the playback pane's position") + } + x, xerr := strconv.Atoi(fields[0]) + y, yerr := strconv.Atoi(fields[1]) + if xerr != nil || yerr != nil || x < 0 || y < 0 { + return 0, 0, fmt.Errorf("tmux reported an invalid playback pane position") + } + if fields[2] == "top" { + lines := 0 + switch fields[3] { + case "on": + lines = 1 + case "off": + default: + lines, err = strconv.Atoi(fields[3]) + if err != nil || lines < 0 || lines > 5 { + return 0, 0, fmt.Errorf("tmux reported an invalid status bar height") + } + } + y += lines + } + return x, y, nil +} + +func (p *PaneZoom) Toggle() (bool, error) { + if _, err := p.run("resize-pane", "-Z", "-t", p.pane); err != nil { + return false, fmt.Errorf("toggle terminal playback fullscreen: %w", err) + } + p.changed = true + // tmux leaves a one-pane window unzoomed. Read the actual result instead + // of assuming every successful command changed the state. + return p.Zoomed() +} + +// Restore preserves a pre-existing zoom and leaves external changes alone if +// playback never used its fullscreen control. +func (p *PaneZoom) Restore() error { + if !p.changed { + return nil + } + zoomed, err := p.Zoomed() + if err != nil { + return err + } + if zoomed != p.initial { + if _, err := p.Toggle(); err != nil { + return err + } + } + p.changed = false + return nil +} diff --git a/internal/termgraphics/pane_test.go b/internal/termgraphics/pane_test.go new file mode 100644 index 0000000..6efbc26 --- /dev/null +++ b/internal/termgraphics/pane_test.go @@ -0,0 +1,154 @@ +package termgraphics + +import ( + "errors" + "fmt" + "slices" + "testing" +) + +func TestPaneZoomKeepsInitialLayout(t *testing.T) { + for _, tc := range []struct { + name string + initial bool + toggles int + }{ + {"split unchanged", false, 0}, {"already zoomed unchanged", true, 0}, + {"restore split after fullscreen", false, 1}, {"restore original zoom", true, 1}, + {"fullscreen toggled twice", false, 2}, {"original zoom toggled twice", true, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + zoomed, mutations := tc.initial, 0 + run := func(args ...string) ([]byte, error) { + switch { + case slices.Equal(args, []string{"display-message", "-p", "-t", "%42", "#{window_zoomed_flag}"}): + if zoomed { + return []byte("1\n"), nil + } + return []byte("0\n"), nil + case slices.Equal(args, []string{"resize-pane", "-Z", "-t", "%42"}): + mutations++ + zoomed = !zoomed + return nil, nil + default: + return nil, fmt.Errorf("unexpected tmux command: %q", args) + } + } + p, err := newPaneZoom("%42", run) + if err != nil { + t.Fatal(err) + } + if mutations != 0 || zoomed != tc.initial { + t.Fatal("starting playback changed the pane layout") + } + for range tc.toggles { + before := zoomed + got, err := p.Toggle() + if err != nil || got == before || got != zoomed { + t.Fatalf("toggle: zoomed=%v, err=%v", got, err) + } + } + if err := p.Restore(); err != nil { + t.Fatal(err) + } + if zoomed != tc.initial { + t.Fatal("playback did not restore the original zoom") + } + before := mutations + if err := p.Restore(); err != nil || mutations != before { + t.Fatal("restoration was not idempotent", err) + } + if tc.toggles == 0 && mutations != 0 { + t.Fatal("playback zoomed without user input") + } + }) + } +} + +func TestPaneZoomLeavesExternalChangesAlone(t *testing.T) { + state := "0" + p, err := newPaneZoom("%42", func(args ...string) ([]byte, error) { + if args[0] != "display-message" { + t.Fatal("restoration changed an externally zoomed pane") + } + return []byte(state), nil + }) + if err != nil { + t.Fatal(err) + } + state = "1" + if err := p.Restore(); err != nil { + t.Fatal(err) + } +} + +func TestPaneOrigin(t *testing.T) { + for _, tc := range []struct { + name, reply string + x, y int + wantErr bool + }{ + {"right split", "74 0 bottom on", 74, 0, false}, + {"lower split", "0 25 bottom on", 0, 25, false}, + {"status above", "12 8 top on", 12, 9, false}, + {"two status rows", "12 8 top 2", 12, 10, false}, + {"no status", "12 8 top off", 12, 8, false}, + {"missing pane", "", 0, 0, true}, + {"invalid coordinates", "bad 8 top on", 0, 0, true}, + {"negative coordinates", "-1 0 bottom on", 0, 0, true}, + {"invalid status", "0 0 top invalid", 0, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + p := &PaneZoom{pane: "%42", run: func(args ...string) ([]byte, error) { + if !slices.Equal(args, []string{"display-message", "-p", "-t", "%42", "#{pane_left} #{pane_top} #{status-position} #{status}"}) { + t.Fatalf("unexpected position query: %q", args) + } + return []byte(tc.reply), nil + }} + x, y, err := p.Origin() + if (err != nil) != tc.wantErr || x != tc.x || y != tc.y { + t.Fatalf("origin = %d,%d, %v; want %d,%d, error=%v", x, y, err, tc.x, tc.y, tc.wantErr) + } + }) + } +} + +func TestPaneZoomFailureAndSinglePane(t *testing.T) { + failed := errors.New("tmux failed") + if _, err := newPaneZoom("%42", func(...string) ([]byte, error) { return nil, failed }); !errors.Is(err, failed) { + t.Fatalf("inspect error lost: %v", err) + } + if _, err := newPaneZoom("%42", func(...string) ([]byte, error) { return []byte(""), nil }); err == nil { + t.Fatal("missing pane state was accepted") + } + p, err := newPaneZoom("%42", func(args ...string) ([]byte, error) { + if args[0] == "resize-pane" { + return nil, failed + } + return []byte("0"), nil + }) + if err != nil { + t.Fatal(err) + } + if _, err := p.Toggle(); !errors.Is(err, failed) { + t.Fatalf("toggle error lost: %v", err) + } + if err := p.Restore(); err != nil { + t.Fatal("failed toggle should not need restoration", err) + } + p, err = newPaneZoom("%42", func(...string) ([]byte, error) { return []byte("0"), nil }) + if err != nil { + t.Fatal(err) + } + if zoomed, err := p.Toggle(); err != nil || zoomed { + t.Fatalf("single pane was incorrectly marked fullscreen: %v, %v", zoomed, err) + } +} + +func TestPaneZoomOutsideTmux(t *testing.T) { + t.Setenv("TMUX", "") + p, err := NewPaneZoom() + if err != nil || p != nil { + t.Fatalf("unexpected tmux controller outside tmux: %v, %v", p, err) + } +} diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go index 7fa3ab6..f25b630 100644 --- a/internal/termgraphics/terminal.go +++ b/internal/termgraphics/terminal.go @@ -152,38 +152,6 @@ func Wrap(sequence string) string { return sequence } -// ZoomPane gives passthrough video the terminal area while it owns input. tmux -// cannot clip a passthrough image to a split pane. Restore the previous layout -// after playback, including on a player error. -func ZoomPane() (func(), error) { - noop := func() {} - if !InTmux() { - return noop, nil - } - pane := os.Getenv("TMUX_PANE") - run := func(args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - return exec.CommandContext(ctx, "tmux", args...).Output() - } - zoom, err := run("display-message", "-p", "-t", pane, "#{window_zoomed_flag}") - if err != nil { - return noop, fmt.Errorf("inspect tmux pane: %w", err) - } - if strings.TrimSpace(string(zoom)) == "1" { - return noop, nil - } - if _, err := run("resize-pane", "-Z", "-t", pane); err != nil { - return noop, fmt.Errorf("zoom terminal playback pane: %w", err) - } - return func() { - zoom, err := run("display-message", "-p", "-t", pane, "#{window_zoomed_flag}") - if err == nil && strings.TrimSpace(string(zoom)) == "1" { - _, _ = run("resize-pane", "-Z", "-t", pane) - } - }, nil -} - var attributes = regexp.MustCompile(`\x1b\[\?([0-9;]+)c`) var terminalName = regexp.MustCompile(`\x1bP>\|([^\x1b]+)\x1b\\`) var cellSizeReply = regexp.MustCompile(`\x1b\[6;(\d+);(\d+)t`) diff --git a/internal/termgraphics/writer.go b/internal/termgraphics/writer.go index 64a40c0..819224f 100644 --- a/internal/termgraphics/writer.go +++ b/internal/termgraphics/writer.go @@ -29,6 +29,20 @@ type Writer struct { cursorX, cursorY int mu sync.RWMutex frame Frame + originX, originY int + originSet bool +} + +// SetPaneOrigin anchors each passthrough packet in the physical terminal. +// tmux forwards raw graphics without moving its cursor and resets that cursor +// between packets; a pane-relative CSI alone cannot position a split's video. +// It returns whether the placement changed, so paused video can be redrawn. +func (w *Writer) SetPaneOrigin(x, y int) bool { + w.mu.Lock() + defer w.mu.Unlock() + changed := !w.originSet || w.originX != x || w.originY != y + w.originX, w.originY, w.originSet = x, y, true + return changed } // Frame describes the actual Kitty placement: a cell origin and pixel size. @@ -132,6 +146,13 @@ func (w *Writer) Write(p []byte) (n int, err error) { } packet := sequence if !w.Direct { + w.mu.RLock() + if w.originSet { + // Keep the absolute cursor move inside the same passthrough as + // the graphics, including continuation packets after a reset. + sequence = fmt.Sprintf("\x1b[%d;%dH", w.originY+w.cursorY+1, w.originX+w.cursorX+1) + sequence + } + w.mu.RUnlock() packet = ansi.TmuxPassthrough(sequence) } if _, err := w.buffered.WriteString(packet); err != nil { diff --git a/internal/termgraphics/writer_test.go b/internal/termgraphics/writer_test.go index 0055dd8..b825448 100644 --- a/internal/termgraphics/writer_test.go +++ b/internal/termgraphics/writer_test.go @@ -9,6 +9,41 @@ import ( "github.com/charmbracelet/x/ansi" ) +func TestVideoAnchorsEveryPacketInTmuxPane(t *testing.T) { + const cursor = "\x1b[13;1f" + const first = "\x1b_Ga=T,f=24,s=531,v=298,m=1;AAAA\x1b\\" + const last = "\x1b_Gm=0;BBBB\x1b\\" + for split := 1; split <= len(cursor+first+last); split++ { + var output bytes.Buffer + w := Writer{Output: &output} + w.SetPaneOrigin(74, 2) + input := cursor + first + last + for at := 0; at < len(input); at += split { + if _, err := w.Write([]byte(input[at:min(at+split, len(input))])); err != nil { + t.Fatal(err) + } + } + want := cursor + ansi.TmuxPassthrough("\x1b[15;75H"+first) + ansi.TmuxPassthrough("\x1b[15;75H"+last) + if output.String() != want { + t.Fatalf("chunk size %d did not anchor every graphics packet in the pane", split) + } + if w.Frame() != (Frame{X: 0, Y: 12, Width: 531, Height: 298}) { + t.Fatal("mouse coordinates must stay relative to the pane", w.Frame()) + } + output.Reset() + if !w.SetPaneOrigin(0, 0) || w.SetPaneOrigin(0, 0) { + t.Fatal("pane movement was not detected") + } + if _, err := w.Write([]byte(cursor + first + last)); err != nil { + t.Fatal(err) + } + want = cursor + ansi.TmuxPassthrough("\x1b[13;1H"+first) + ansi.TmuxPassthrough("\x1b[13;1H"+last) + if output.String() != want { + t.Fatal("graphics kept the old pane offset after fullscreen") + } + } +} + func TestVideoRetainsPreviousFrameDuringUpload(t *testing.T) { const first = "\x1b_Ga=T,f=24,s=2,v=1,m=1;AAAA\x1b\\" const middle = "\x1b_Gm=1;CCCC\x1b\\" diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index dbee283..1afa471 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -203,43 +203,60 @@ func (p *terminalPlayback) run() error { var graphics *termgraphics.Writer var controls *terminalControls var cellW, cellH float64 + paneZoom, err := termgraphics.NewPaneZoom() + if err != nil { + return err + } + if paneZoom != nil { + defer func() { + if err := paneZoom.Restore(); err != nil { + slog.Warn("Restore terminal playback pane", "error", err) + } + }() + } if termgraphics.InTmux() && (p.output == config.VideoKitty || p.output == config.VideoSixel) { restore, err := termgraphics.EnablePassthrough() if err != nil { return err } defer restore() - unzoom, err := termgraphics.ZoomPane() - if err != nil { - return err - } - defer unzoom() if p.output == config.VideoSixel { - cmd.Stdout = &termgraphics.Writer{Output: p.stdout} + graphics = &termgraphics.Writer{Output: p.stdout} + cmd.Stdout = graphics defer func() { - _, _ = io.WriteString(p.stdout, termgraphics.Wrap("\x1b[2J\x1b[H")) + _, _ = io.WriteString(p.stdout, "\x1b[2J\x1b[H") }() } } if p.output == config.VideoKitty { controls = &terminalControls{focus: -1, hover: -1, speed: 1, subtitles: true} + controls.canFullscreen = paneZoom != nil + if paneZoom != nil { + controls.fullscreen, err = paneZoom.Zoomed() + if err != nil { + return err + } + } p.status.Volume = 100 graphics = &termgraphics.Writer{Output: p.stdout, Direct: !termgraphics.InTmux(), ImageID: (1 << 30) + 2*rand.IntN(1<<19)} cmd.Stdout = graphics defer func() { _, _ = io.WriteString(p.stdout, termgraphics.Wrap(graphics.DeleteImages())) }() } if p.output == config.VideoSixel { - columns, rows := 80, 24 - cw, ch := termgraphics.Current().CellWidth, termgraphics.Current().CellHeight - if input, ok := p.stdin.(*os.File); ok { - if w, h, err := term.GetSize(int(input.Fd())); err == nil { - columns, rows = w, h - } - if w, h := terminalCellSize(input); w > 0 && h > 0 { - cw, ch = int(w), int(h) - } + cmd.Args = append(cmd.Args, p.sixelArgs()...) + } + updateOrigin := func() (bool, error) { + if paneZoom == nil || graphics == nil { + return false, nil } - cmd.Args = append(cmd.Args, sixelSizeArgs(p.sixelWidth, columns, rows, cw, ch)...) + x, y, err := paneZoom.Origin() + if err != nil { + return false, err + } + return graphics.SetPaneOrigin(x, y), nil + } + if _, err := updateOrigin(); err != nil { + return err } // Our wrapper handles actual tmux panes. Also remove stale tmux variables // from direct Kitty windows so mpv cannot wrap their graphics by mistake. @@ -356,6 +373,7 @@ func (p *terminalPlayback) run() error { controlTicker := time.NewTicker(100 * time.Millisecond) defer controlTicker.Stop() var controlColumns, controlRows int + lastPaneCheck := time.Now() keyTimer := time.NewTimer(time.Hour) keyTimer.Stop() defer keyTimer.Stop() @@ -379,6 +397,8 @@ func (p *terminalPlayback) run() error { } frame := graphics.Frame() command = controls.input(input, controls.layout(frame, p.status), frame, cellW, cellH, p.status) + } else if input.Key == "f" && paneZoom != nil { + command = []any{"cycle", "fullscreen"} } else { key := input.Key if input.Mouse != nil { @@ -391,6 +411,20 @@ func (p *terminalPlayback) run() error { if len(command) == 0 { continue } + if paneZoom != nil && len(command) == 2 && command[0] == "cycle" && command[1] == "fullscreen" { + zoomed, err := paneZoom.Toggle() + if err != nil { + return err + } + if _, err := updateOrigin(); err != nil { + return err + } + if controls != nil { + controls.fullscreen = zoomed + controls.dragging, controls.hover = false, -1 + } + continue + } if err := sendTerminalCommand(conn, 0, command...); err != nil { return err } @@ -483,10 +517,9 @@ func (p *terminalPlayback) run() error { return err } case <-controlTicker.C: - if controls == nil || !loaded { + if !loaded { continue } - layout := controls.layout(graphics.Frame(), p.status) resized := false if input, ok := p.stdin.(*os.File); ok { if columns, rows, err := term.GetSize(int(input.Fd())); err == nil { @@ -494,6 +527,31 @@ func (p *terminalPlayback) run() error { controlColumns, controlRows = columns, rows } } + if resized || time.Since(lastPaneCheck) >= time.Second { + moved, err := updateOrigin() + if err != nil { + return err + } + resized = resized || moved + lastPaneCheck = time.Now() + } + if resized && p.output == config.VideoSixel { + for _, arg := range p.sixelArgs() { + option, value, _ := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + if err := sendTerminalCommand(conn, 0, "set_property", option, value); err != nil { + return err + } + } + } + if controls == nil { + continue + } + if resized && paneZoom != nil { + if zoomed, err := paneZoom.Zoomed(); err == nil { + controls.fullscreen = zoomed + } + } + layout := controls.layout(graphics.Frame(), p.status) overlay := controls.overlay(layout, p.status) // A paused Kitty VO needs an OSD redraw to process SIGWINCH. // Resending the overlay preserves pause and the playback position. @@ -540,6 +598,20 @@ func (p *terminalPlayback) run() error { } } +func (p *terminalPlayback) sixelArgs() []string { + columns, rows := 80, 24 + cw, ch := termgraphics.Current().CellWidth, termgraphics.Current().CellHeight + if input, ok := p.stdin.(*os.File); ok { + if w, h, err := term.GetSize(int(input.Fd())); err == nil { + columns, rows = w, h + } + if w, h := terminalCellSize(input); w > 0 && h > 0 { + cw, ch = int(w), int(h) + } + } + return sixelSizeArgs(p.sixelWidth, columns, rows, cw, ch) +} + func awaitQueueLoad(events <-chan terminalEvent, pending *[]terminalEvent) error { timer := time.NewTimer(5 * time.Second) defer timer.Stop() diff --git a/internal/tui/player/terminal_controls.go b/internal/tui/player/terminal_controls.go index a04ce03..13deac6 100644 --- a/internal/tui/player/terminal_controls.go +++ b/internal/tui/player/terminal_controls.go @@ -13,13 +13,15 @@ import ( // text over that image would be erased on every video frame. A single layout // supplies both the ASS overlay and the mouse hit targets. type terminalControls struct { - hidden bool - focus int - hover int - dragging bool - speed float64 - subtitles bool - lastOverlay string + hidden bool + focus int + hover int + dragging bool + speed float64 + subtitles bool + canFullscreen bool + fullscreen bool + lastOverlay string } type videoRect struct{ x, y, w, h float64 } @@ -45,6 +47,9 @@ func (c *terminalControls) layout(frame termgraphics.Frame, status PlayerStatus) return videoControlsLayout{} } w := max(720, min(1080, frame.Width)) + if c.canFullscreen { + w = max(w, 840) + } h := max(120, int(float64(w)*float64(frame.Height)/float64(frame.Width))) l := videoControlsLayout{w: w, h: h, bar: videoRect{0, float64(h) - 112, float64(w), 112}, seek: videoRect{18, float64(h) - 71, float64(w) - 36, 20}} @@ -75,6 +80,13 @@ func (c *terminalControls) layout(frame termgraphics.Frame, status PlayerStatus) nextSpeed = .5 } add(514, 62, fmt.Sprintf("%gx", c.speed), "set_property", "speed", nextSpeed) + if c.canFullscreen { + label := "Fullscreen" + if c.fullscreen { + label = "Restore" + } + add(float64(w)-238, 104, label, "cycle", "fullscreen") + } add(float64(w)-126, 108, "Return", "quit") return l } @@ -126,6 +138,9 @@ func (c *terminalControls) overlay(l videoControlsLayout, status PlayerStatus) s } rect(l.bar, "211B15", 24) title := videoLabel(status.Title, (l.w-420)/11) + " [h: hide]" + if c.canFullscreen { + title = videoLabel(status.Title, max(0, (l.w-580)/11)) + " [f: fullscreen, h: hide]" + } if c.focus >= 0 { title = "Tab: next control Enter: activate Esc: return" } @@ -168,6 +183,10 @@ func videoMousePosition(m playbackMouse, frame termgraphics.Frame, cellW, cellH func (c *terminalControls) input(input playbackInput, l videoControlsLayout, frame termgraphics.Frame, cellW, cellH float64, status PlayerStatus) []any { if input.Mouse == nil { switch input.Key { + case "f": + if c.canFullscreen { + return []any{"cycle", "fullscreen"} + } case "h": c.hidden, c.dragging, c.focus = !c.hidden, false, -1 return nil diff --git a/internal/tui/player/terminal_controls_test.go b/internal/tui/player/terminal_controls_test.go index 1b7b26a..92f6d96 100644 --- a/internal/tui/player/terminal_controls_test.go +++ b/internal/tui/player/terminal_controls_test.go @@ -16,7 +16,7 @@ func TestVideoControlsMouse(t *testing.T) { {X: 12, Y: 6, Width: 720, Height: 406}, {X: 35, Y: 1, Width: 450, Height: 800}, } { - c := terminalControls{focus: -1, hover: -1, speed: 1} + c := terminalControls{focus: -1, hover: -1, speed: 1, canFullscreen: true} status := PlayerStatus{Time: 30, Duration: 120, Volume: 80} l := c.layout(frame, status) at := func(x, y float64, button int, pressed bool) []any { @@ -28,7 +28,7 @@ func TestVideoControlsMouse(t *testing.T) { for i, want := range [][]any{ {"cycle", "pause"}, {"seek", -10, "relative+exact"}, {"seek", 10, "relative+exact"}, {"cycle", "mute"}, {"add", "volume", -5}, {"add", "volume", 5}, - {"cycle", "sub-visibility"}, {"set_property", "speed", 1.25}, {"quit"}, + {"cycle", "sub-visibility"}, {"set_property", "speed", 1.25}, {"cycle", "fullscreen"}, {"quit"}, } { button := l.buttons[i] got := at(button.x+button.w/2, button.y+button.h/2, 0, true) @@ -62,6 +62,45 @@ func TestVideoControlsMouse(t *testing.T) { } } +func TestVideoControlsFullscreen(t *testing.T) { + c := terminalControls{focus: -1, hover: -1, speed: 1, canFullscreen: true} + frame := termgraphics.Frame{Width: 450, Height: 254} + status := PlayerStatus{Duration: 120} + l := c.layout(frame, status) + for i, b := range l.buttons { + if b.x < 0 || b.x+b.w > float64(l.w) || (i > 0 && b.x < l.buttons[i-1].x+l.buttons[i-1].w) { + t.Fatal("fullscreen control overlaps another button in a small pane") + } + } + want := []any{"cycle", "fullscreen"} + if !strings.Contains(c.overlay(l, status), "Fullscreen") || !strings.Contains(c.overlay(l, status), "f: fullscreen") { + t.Fatal("fullscreen button or shortcut hint is missing") + } + for _, hidden := range []bool{false, true} { + c.hidden = hidden + if got := c.input(playbackInput{Key: "f"}, l, frame, 8, 16, status); !reflect.DeepEqual(got, want) { + t.Fatalf("fullscreen shortcut (hidden=%v): %v", hidden, got) + } + } + c.hidden = false + c.focus = len(l.buttons) - 2 + if got := c.input(playbackInput{Key: "ENTER"}, l, frame, 8, 16, status); !reflect.DeepEqual(got, want) { + t.Fatal("fullscreen button cannot be activated from the keyboard", got) + } + c.fullscreen = true + if !strings.Contains(c.overlay(c.layout(frame, status), status), "Restore") { + t.Fatal("fullscreen button does not show how to restore the pane") + } + c.canFullscreen = false + l = c.layout(frame, status) + if strings.Contains(c.overlay(l, status), "Fullscreen") || strings.Contains(c.overlay(l, status), "f: fullscreen") { + t.Fatal("tmux fullscreen control shown outside tmux") + } + if got := c.input(playbackInput{Key: "f"}, l, frame, 8, 16, status); !reflect.DeepEqual(got, []any{"keypress", "f"}) { + t.Fatal("mpv fullscreen binding outside tmux was lost", got) + } +} + func TestVideoControlsKeyboardAndDisplay(t *testing.T) { c := terminalControls{focus: -1, hover: -1, speed: 1} frame := termgraphics.Frame{Width: 1280, Height: 720} From f132ca3681b16706deab1b8c0cd6dc1ef3507d05 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:58:15 -0400 Subject: [PATCH 20/21] fix: harden terminal playback and mpv build cleanup --- internal/mpvsetup/process_linux.go | 7 ++ internal/mpvsetup/process_other.go | 11 ++- internal/mpvsetup/source.go | 3 +- internal/mpvsetup/source_integration_test.go | 72 ++++++++++++++++++ internal/termgraphics/pane.go | 59 +++++++++----- .../termgraphics/pane_integration_test.go | 76 +++++++++++++++++++ internal/termgraphics/pane_test.go | 14 ++-- internal/tui/player/overlay.go | 9 ++- .../tui/player/overlay_integration_test.go | 53 +++++++++++++ internal/tui/player/terminal.go | 63 ++++++++++----- .../player/terminal_queue_integration_test.go | 46 +++++++++++ internal/tui/player/video_output.go | 38 +++++++++- internal/tui/player/visualizer_test.go | 4 +- 13 files changed, 401 insertions(+), 54 deletions(-) create mode 100644 internal/termgraphics/pane_integration_test.go diff --git a/internal/mpvsetup/process_linux.go b/internal/mpvsetup/process_linux.go index 56405d6..e27b999 100644 --- a/internal/mpvsetup/process_linux.go +++ b/internal/mpvsetup/process_linux.go @@ -1,11 +1,18 @@ package mpvsetup import ( + "context" + "os" "os/exec" + "os/signal" "syscall" "time" ) +func buildContext(ctx context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) +} + func prepareBuildCommand(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Meson spawns Ninja and compilers; cancel the entire build group so they diff --git a/internal/mpvsetup/process_other.go b/internal/mpvsetup/process_other.go index 133e5a6..c9d09dc 100644 --- a/internal/mpvsetup/process_other.go +++ b/internal/mpvsetup/process_other.go @@ -2,6 +2,15 @@ package mpvsetup -import "os/exec" +import ( + "context" + "os" + "os/exec" + "os/signal" +) + +func buildContext(ctx context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(ctx, os.Interrupt) +} func prepareBuildCommand(_ *exec.Cmd) {} diff --git a/internal/mpvsetup/source.go b/internal/mpvsetup/source.go index 5e7f23a..0b0fe43 100644 --- a/internal/mpvsetup/source.go +++ b/internal/mpvsetup/source.go @@ -7,7 +7,6 @@ import ( "io" "os" "os/exec" - "os/signal" "path/filepath" "runtime" "strconv" @@ -164,7 +163,7 @@ func displayCommand(args []string) string { } func runSourceBuild(ctx context.Context, p sourcePlan, opts Options) error { - ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + ctx, stop := buildContext(ctx) defer stop() if err := runPlan(ctx, p.packages, opts); err != nil { return err diff --git a/internal/mpvsetup/source_integration_test.go b/internal/mpvsetup/source_integration_test.go index 0132380..7595ab7 100644 --- a/internal/mpvsetup/source_integration_test.go +++ b/internal/mpvsetup/source_integration_test.go @@ -9,7 +9,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "syscall" "testing" "time" ) @@ -60,6 +62,7 @@ case "$1" in done ;; compile) + printf '%s' "$$" > "$MPV_TEST_DIR/build-pid" if [ "$MPV_TEST_FAILURE" = compile ]; then printf 'compiler failed\n' >&2; exit 9; fi if [ "$MPV_TEST_FAILURE" = cancel ]; then /bin/sleep 30 & @@ -271,3 +274,72 @@ func TestSourceBuildCancellationStopsCompilers(t *testing.T) { t.Fatal("canceled build activated a player") } } + +func TestSourceBuildSIGTERMCleansUp(t *testing.T) { + dir := fakeSourceTools(t) + t.Setenv("MPV_TEST_FAILURE", "cancel") + t.Setenv("PLEXCTL_BUILD_SIGNAL_HELPER", "1") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "-test.run=^TestSourceBuildSignalHelper$") + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + if data, err := os.ReadFile(filepath.Join(dir, "build-pid")); err == nil { + if pid, err := strconv.Atoi(string(data)); err == nil && pid > 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + } + if cmd.ProcessState == nil { + _ = cmd.Wait() + } + }) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if data, _ := os.ReadFile(filepath.Join(dir, "child-pid")); len(data) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if _, err := os.Stat(filepath.Join(dir, "child-pid")); err != nil { + t.Fatal("compiler did not start") + } + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("SIGTERM bypassed build cleanup: %v\n%s", err, output.String()) + } + if prefix, err := os.ReadFile(filepath.Join(dir, "prefix")); err != nil { + t.Fatal(err) + } else if _, err := os.Stat(string(prefix)); !os.IsNotExist(err) { + t.Fatal("interrupted build left its incomplete installation behind") + } +} + +func TestSourceBuildSignalHelper(t *testing.T) { + if os.Getenv("PLEXCTL_BUILD_SIGNAL_HELPER") != "1" { + return + } + dir := os.Getenv("MPV_TEST_DIR") + deps, err := sourceDependencies(t.Context(), queryCommand) + if err != nil { + t.Fatal(err) + } + p, err := newSourcePlan("0.41.0", dir, dir, deps, true, exec.LookPath) + if err != nil { + t.Fatal(err) + } + err = runSourceBuild(t.Context(), p, Options{Input: strings.NewReader(""), Output: os.Stdout, Error: os.Stderr}) + if err == nil { + t.Fatal("terminated build succeeded") + } +} diff --git a/internal/termgraphics/pane.go b/internal/termgraphics/pane.go index 7d1839e..ab39cdc 100644 --- a/internal/termgraphics/pane.go +++ b/internal/termgraphics/pane.go @@ -16,10 +16,16 @@ import ( type PaneZoom struct { pane string run func(...string) ([]byte, error) - initial bool + initial paneZoomState + last paneZoomState changed bool } +type paneZoomState struct { + window string + zoomed, active bool +} + // NewPaneZoom returns nil outside an actual tmux pane. func NewPaneZoom() (*PaneZoom, error) { if !InTmux() { @@ -34,27 +40,30 @@ func NewPaneZoom() (*PaneZoom, error) { func newPaneZoom(pane string, run func(...string) ([]byte, error)) (*PaneZoom, error) { p := &PaneZoom{pane: pane, run: run} - initial, err := p.Zoomed() + initial, err := p.state() if err != nil { return nil, err } - p.initial = initial + p.initial, p.last = initial, initial return p, nil } func (p *PaneZoom) Zoomed() (bool, error) { - value, err := p.run("display-message", "-p", "-t", p.pane, "#{window_zoomed_flag}") + state, err := p.state() + return state.zoomed && state.active, err +} + +func (p *PaneZoom) state() (paneZoomState, error) { + value, err := p.run("display-message", "-p", "-t", p.pane, "#{window_id} #{window_zoomed_flag} #{pane_active}") if err != nil { - return false, fmt.Errorf("inspect tmux pane zoom: %w", err) + return paneZoomState{}, fmt.Errorf("inspect tmux pane zoom: %w", err) } - switch strings.TrimSpace(string(value)) { - case "0": - return false, nil - case "1": - return true, nil - default: - return false, fmt.Errorf("tmux did not report the playback pane's zoom state") + fields := strings.Fields(string(value)) + if len(fields) != 3 || !strings.HasPrefix(fields[0], "@") || + (fields[1] != "0" && fields[1] != "1") || (fields[2] != "0" && fields[2] != "1") { + return paneZoomState{}, fmt.Errorf("tmux did not report the playback pane's zoom state") } + return paneZoomState{window: fields[0], zoomed: fields[1] == "1", active: fields[2] == "1"}, nil } // Origin is the pane's position in the attached terminal, including a status @@ -91,26 +100,40 @@ func (p *PaneZoom) Origin() (int, int, error) { } func (p *PaneZoom) Toggle() (bool, error) { + before, err := p.state() + if err != nil { + return false, err + } + // A later explicit toggle starts from the user's current layout, including + // changes made outside playback or moving the pane to another window. + if !p.changed || before != p.last { + p.initial = before + } if _, err := p.run("resize-pane", "-Z", "-t", p.pane); err != nil { return false, fmt.Errorf("toggle terminal playback fullscreen: %w", err) } - p.changed = true // tmux leaves a one-pane window unzoomed. Read the actual result instead // of assuming every successful command changed the state. - return p.Zoomed() + after, err := p.state() + if err != nil { + return false, err + } + p.last = after + p.changed = after.zoomed != p.initial.zoomed + return after.zoomed && after.active, nil } -// Restore preserves a pre-existing zoom and leaves external changes alone if -// playback never used its fullscreen control. +// Restore undoes playback's outstanding zoom change only while the same pane +// and window still have the state left by its last fullscreen toggle. func (p *PaneZoom) Restore() error { if !p.changed { return nil } - zoomed, err := p.Zoomed() + current, err := p.state() if err != nil { return err } - if zoomed != p.initial { + if current == p.last && current.zoomed != p.initial.zoomed { if _, err := p.Toggle(); err != nil { return err } diff --git a/internal/termgraphics/pane_integration_test.go b/internal/termgraphics/pane_integration_test.go new file mode 100644 index 0000000..8ed6758 --- /dev/null +++ b/internal/termgraphics/pane_integration_test.go @@ -0,0 +1,76 @@ +//go:build integration && !windows + +package termgraphics + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPaneZoomPreservesLaterTmuxLayout(t *testing.T) { + tmux, err := exec.LookPath("tmux") + if err != nil { + t.Skip("tmux is not installed") + } + for _, scenario := range []string{"peer zoomed", "pane moved", "fullscreen toggled twice"} { + t.Run(scenario, func(t *testing.T) { + // Keep the Unix socket path short and use an isolated server. + dir, err := os.MkdirTemp("", "plexctl-tmux-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "socket") + run := func(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return exec.CommandContext(ctx, tmux, append([]string{"-S", socket, "-f", "/dev/null"}, args...)...).CombinedOutput() + } + must := func(args ...string) string { + t.Helper() + out, err := run(args...) + if err != nil { + t.Fatalf("tmux %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + t.Cleanup(func() { _, _ = run("kill-server") }) + pane := must("new-session", "-d", "-s", "review", "-x", "100", "-y", "30", "-P", "-F", "#{pane_id}") + peer := must("split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}") + p, err := newPaneZoom(pane, run) + if err != nil { + t.Fatal(err) + } + if _, err := p.Toggle(); err != nil { + t.Fatal(err) + } + switch scenario { + case "peer zoomed": + must("resize-pane", "-Z", "-t", pane) + must("resize-pane", "-Z", "-t", peer) + case "pane moved": + must("break-pane", "-d", "-s", pane, "-t", "review:") + must("split-window", "-d", "-h", "-t", pane) + must("resize-pane", "-Z", "-t", pane) + case "fullscreen toggled twice": + if _, err := p.Toggle(); err != nil { + t.Fatal(err) + } + must("resize-pane", "-Z", "-t", pane) + } + before := must("list-panes", "-a", "-F", "#{window_id} #{pane_id} #{pane_active} #{window_zoomed_flag}") + if err := p.Restore(); err != nil { + t.Fatal(err) + } + after := must("list-panes", "-a", "-F", "#{window_id} #{pane_id} #{pane_active} #{window_zoomed_flag}") + if after != before { + t.Fatalf("exiting playback changed the user's later layout:\nbefore:\n%s\nafter:\n%s", before, after) + } + }) + } +} diff --git a/internal/termgraphics/pane_test.go b/internal/termgraphics/pane_test.go index 6efbc26..56da650 100644 --- a/internal/termgraphics/pane_test.go +++ b/internal/termgraphics/pane_test.go @@ -21,11 +21,11 @@ func TestPaneZoomKeepsInitialLayout(t *testing.T) { zoomed, mutations := tc.initial, 0 run := func(args ...string) ([]byte, error) { switch { - case slices.Equal(args, []string{"display-message", "-p", "-t", "%42", "#{window_zoomed_flag}"}): + case slices.Equal(args, []string{"display-message", "-p", "-t", "%42", "#{window_id} #{window_zoomed_flag} #{pane_active}"}): if zoomed { - return []byte("1\n"), nil + return []byte("@1 1 1\n"), nil } - return []byte("0\n"), nil + return []byte("@1 0 1\n"), nil case slices.Equal(args, []string{"resize-pane", "-Z", "-t", "%42"}): mutations++ zoomed = !zoomed @@ -66,7 +66,7 @@ func TestPaneZoomKeepsInitialLayout(t *testing.T) { } func TestPaneZoomLeavesExternalChangesAlone(t *testing.T) { - state := "0" + state := "@1 0 1" p, err := newPaneZoom("%42", func(args ...string) ([]byte, error) { if args[0] != "display-message" { t.Fatal("restoration changed an externally zoomed pane") @@ -76,7 +76,7 @@ func TestPaneZoomLeavesExternalChangesAlone(t *testing.T) { if err != nil { t.Fatal(err) } - state = "1" + state = "@1 1 1" if err := p.Restore(); err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestPaneZoomFailureAndSinglePane(t *testing.T) { if args[0] == "resize-pane" { return nil, failed } - return []byte("0"), nil + return []byte("@1 0 1"), nil }) if err != nil { t.Fatal(err) @@ -136,7 +136,7 @@ func TestPaneZoomFailureAndSinglePane(t *testing.T) { if err := p.Restore(); err != nil { t.Fatal("failed toggle should not need restoration", err) } - p, err = newPaneZoom("%42", func(...string) ([]byte, error) { return []byte("0"), nil }) + p, err = newPaneZoom("%42", func(...string) ([]byte, error) { return []byte("@1 0 1"), nil }) if err != nil { t.Fatal(err) } diff --git a/internal/tui/player/overlay.go b/internal/tui/player/overlay.go index 72343ff..52fd262 100644 --- a/internal/tui/player/overlay.go +++ b/internal/tui/player/overlay.go @@ -78,9 +78,12 @@ func (pm *PlayerManager) ResizeOverlay() tea.Cmd { if pm.overlay.Load() == nil || pm.conn == nil { return nil } - for _, arg := range overlayArgs(pm.output, config.Get().SixelVideoWidth) { - option, value, _ := strings.Cut(strings.TrimPrefix(arg, "--"), "=") - _ = pm.conn.call("set_property", option, value) + var graph string + if err := pm.conn.get("lavfi-complex", &graph); err != nil { + return err + } + if err := resizeVideoOutput(pm.output, overlayArgs(pm.output, config.Get().SixelVideoWidth), graph, pm.conn.call); err != nil { + return err } pm.redrawOverlay.Store(true) return nil diff --git a/internal/tui/player/overlay_integration_test.go b/internal/tui/player/overlay_integration_test.go index 4184787..7f16f60 100644 --- a/internal/tui/player/overlay_integration_test.go +++ b/internal/tui/player/overlay_integration_test.go @@ -4,6 +4,7 @@ package player import ( "context" + "math" "os" "os/exec" "path/filepath" @@ -50,6 +51,8 @@ func TestOverlayPlaybackWithRealMPV(t *testing.T) { t.Cleanup(func() { layout.Update(w, h, false) }) for _, output := range []config.VideoOutput{config.VideoKitty, config.VideoSixel, config.VideoTCT, config.VideoAuto} { t.Run(string(output), func(t *testing.T) { + layout.Update(78, 24, false) + config.Get().SixelVideoWidth = 160 if output == config.VideoAuto { t.Setenv("TERM", "xterm-kitty") t.Setenv("TERM_PROGRAM", "kitty") @@ -97,6 +100,56 @@ func TestOverlayPlaybackWithRealMPV(t *testing.T) { } pm.TogglePause()() waitAudio(t, pm, "pause overlay", func(s PlayerStatus) bool { return s.Paused() }) + var beforeSize, afterSize struct{ W, H int } + var beforeTime, afterTime float64 + var paused bool + pm.ioMu.Lock() + err = pm.conn.get("osd-dimensions", &beforeSize) + if err == nil { + err = pm.conn.get("time-pos", &beforeTime) + } + pm.ioMu.Unlock() + if err != nil { + t.Fatal(err) + } + layout.Update(110, 35, false) + config.Get().SixelVideoWidth = 0 + if msg := pm.ResizeOverlay()(); msg != nil { + t.Fatalf("resize paused overlay: %v", msg) + } + // VO replacement is asynchronous even after set_property succeeds. + resizeDeadline := time.Now().Add(2 * time.Second) + var configured bool + for time.Now().Before(resizeDeadline) { + pm.ioMu.Lock() + err = pm.conn.get("vo-configured", &configured) + if err == nil { + err = pm.conn.get("osd-dimensions", &afterSize) + } + pm.ioMu.Unlock() + // TCT does not expose OSD dimensions; graphics VOs do. + if err != nil || configured && (want == config.VideoTCT || afterSize.W > beforeSize.W && afterSize.H > beforeSize.H) { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil || !configured || want != config.VideoTCT && (afterSize.W <= beforeSize.W || afterSize.H <= beforeSize.H) { + t.Fatalf("active video did not grow with the overlay: before=%+v after=%+v err=%v", beforeSize, afterSize, err) + } + pm.ioMu.Lock() + if err == nil { + err = pm.conn.get("time-pos", &afterTime) + } + if err == nil { + err = pm.conn.get("pause", &paused) + } + pm.ioMu.Unlock() + if err != nil { + t.Fatal(err) + } + if !paused || math.Abs(afterTime-beforeTime) > .1 { + t.Fatalf("resizing changed playback: paused=%v time=%g, want %g", paused, afterTime, beforeTime) + } pm.SetOverlayVisible(false) pm.SetOverlayVisible(true) before, _ := file.Stat() diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index 1afa471..c514b83 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -142,16 +142,29 @@ func (p *terminalPlayback) args(socket string) []string { return args } -func (p *terminalPlayback) Run() error { - if p.request != nil && p.request.Err() != nil { +func (p *terminalPlayback) Run() (err error) { + ctx := p.request + if ctx == nil { + ctx = context.Background() + } + // Superseded playback is a normal stop, including during output fallback. + defer func() { + if ctx.Err() != nil { + err = nil + } + }() + if ctx.Err() != nil { return nil } initial := p.status - err := p.run() + err = p.run() + if ctx.Err() != nil { + return nil + } if err == nil || p.preference != config.VideoAuto || p.started || !p.attempted || p.output == config.VideoTCT { return err } - if _, _, fallbackErr := terminalOutput(context.Background(), config.VideoTCT, os.Getenv); fallbackErr != nil { + if _, _, fallbackErr := terminalOutput(ctx, config.VideoTCT, os.Getenv); fallbackErr != nil { return err } p.fallback, p.output, p.preference = p.output, config.VideoTCT, config.VideoTCT @@ -160,6 +173,12 @@ func (p *terminalPlayback) Run() error { } func (p *terminalPlayback) run() error { + request := p.request + if request == nil { + request = context.Background() + } + ctx, cancel := context.WithCancel(request) + defer cancel() for _, stream := range []any{p.stdin, p.stdout} { if f, ok := stream.(interface{ Fd() uintptr }); ok && !term.IsTerminal(int(f.Fd())) { return fmt.Errorf("terminal video requires an interactive terminal") @@ -170,7 +189,7 @@ func (p *terminalPlayback) run() error { p.sharedMemory = caps.KittySharedMemory if p.preference != config.VideoWindow { var err error - p.mpv, p.output, err = terminalOutput(context.Background(), p.preference, os.Getenv) + p.mpv, p.output, err = terminalOutput(ctx, p.preference, os.Getenv) if err != nil { return err } @@ -197,7 +216,7 @@ func (p *terminalPlayback) run() error { socket = `\\.\pipe\` + filepath.Base(dir) } - cmd := exec.Command(p.mpv, p.args(socket)...) + cmd := exec.CommandContext(ctx, p.mpv, p.args(socket)...) cmd.Stdin, cmd.Stdout, cmd.Stderr = p.stdin, p.stdout, io.Discard var keys <-chan string var graphics *termgraphics.Writer @@ -303,12 +322,10 @@ func (p *terminalPlayback) run() error { } }() - conn, err := connectTerminal(socket, done) + conn, err := connectTerminal(ctx, socket, done) if err != nil { return err } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() events := make(chan terminalEvent, 64) readerDone := make(chan struct{}) go func() { @@ -327,6 +344,9 @@ func (p *terminalPlayback) run() error { if controls != nil { properties = append(properties, "volume", "mute", "speed", "sub-visibility") } + graphPropertyID := len(properties) + 1 + properties = append(properties, "lavfi-complex") + videoGraph := p.visualizer for id, property := range properties { if err := sendTerminalCommand(conn, 0, "observe_property", id+1, property); err != nil { return err @@ -353,7 +373,7 @@ func (p *terminalPlayback) run() error { } // Drain acknowledgements while building large artist queues. Otherwise // a full event channel can block mpv's IPC output and our next write. - if err := awaitQueueLoad(events, &pending); err != nil { + if err := awaitQueueLoad(ctx, events, &pending); err != nil { return err } } @@ -472,6 +492,10 @@ func (p *terminalPlayback) run() error { } p.reportStatus() case "property-change": + if ev.ID == graphPropertyID { + _ = json.Unmarshal(ev.Data, &videoGraph) + return nil + } oldState := p.status.State p.applyProperty(ev) if controls != nil && len(ev.Data) > 0 && string(ev.Data) != "null" { @@ -502,6 +526,8 @@ func (p *terminalPlayback) run() error { } for { select { + case <-ctx.Done(): + return ctx.Err() case data, ok := <-keys: if !ok { keys = nil @@ -536,11 +562,10 @@ func (p *terminalPlayback) run() error { lastPaneCheck = time.Now() } if resized && p.output == config.VideoSixel { - for _, arg := range p.sixelArgs() { - option, value, _ := strings.Cut(strings.TrimPrefix(arg, "--"), "=") - if err := sendTerminalCommand(conn, 0, "set_property", option, value); err != nil { - return err - } + if err := resizeVideoOutput(p.output, p.sixelArgs(), videoGraph, func(args ...any) error { + return sendTerminalCommand(conn, 0, args...) + }); err != nil { + return err } } if controls == nil { @@ -612,11 +637,13 @@ func (p *terminalPlayback) sixelArgs() []string { return sixelSizeArgs(p.sixelWidth, columns, rows, cw, ch) } -func awaitQueueLoad(events <-chan terminalEvent, pending *[]terminalEvent) error { +func awaitQueueLoad(ctx context.Context, events <-chan terminalEvent, pending *[]terminalEvent) error { timer := time.NewTimer(5 * time.Second) defer timer.Stop() for { select { + case <-ctx.Done(): + return ctx.Err() case event, ok := <-events: if !ok { return fmt.Errorf("mpv disconnected while building the playback queue") @@ -695,13 +722,15 @@ func (p *terminalPlayback) applyProperty(ev terminalEvent) { } } -func connectTerminal(socket string, done <-chan struct{}) (net.Conn, error) { +func connectTerminal(ctx context.Context, socket string, done <-chan struct{}) (net.Conn, error) { timeout := time.NewTimer(5 * time.Second) defer timeout.Stop() retry := time.NewTicker(50 * time.Millisecond) defer retry.Stop() for { select { + case <-ctx.Done(): + return nil, ctx.Err() case <-done: return nil, fmt.Errorf("mpv exited before terminal playback started; check 'mpv --vo=help'") case <-timeout.C: diff --git a/internal/tui/player/terminal_queue_integration_test.go b/internal/tui/player/terminal_queue_integration_test.go index 0ff6e16..1071637 100644 --- a/internal/tui/player/terminal_queue_integration_test.go +++ b/internal/tui/player/terminal_queue_integration_test.go @@ -13,10 +13,56 @@ import ( "path/filepath" "strings" "testing" + "time" + "github.com/LukeHagar/plexgo/models/operations" "github.com/ygelfand/plexctl/internal/config" ) +func TestTerminalPlaybackStopsWhenRequestIsCanceled(t *testing.T) { + mpv, err := exec.LookPath("mpv") + if err != nil { + t.Skip("mpv is not installed") + } + for _, fallback := range []bool{false, true} { + t.Run(fmt.Sprintf("fallback=%v", fallback), func(t *testing.T) { + dir := t.TempDir() + wrapper := filepath.Join(dir, "mpv") + script := "#!/bin/sh\nfor arg; do [ \"$arg\" = --vo=kitty ] && exit 3; done\nexec '" + strings.ReplaceAll(mpv, "'", "'\\''") + "' --no-config --ao=null \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + playback := &terminalPlayback{ + mpv: wrapper, output: config.VideoOutput("null"), stdin: strings.NewReader(""), stdout: io.Discard, + request: ctx, items: []PlayItem{{URL: testAudio(t, dir), RatingKey: "cancel-me"}}, + } + if fallback { + playback.output, playback.preference = config.VideoKitty, config.VideoAuto + } + setTrack(&playback.status, playback.items[0]) + var canceledAt time.Time + playback.report = func(_ context.Context, status PlayerStatus) { + if status.State == operations.StatePlaying && canceledAt.IsZero() { + canceledAt = time.Now() + cancel() + } + } + if err := playback.Run(); err != nil { + t.Fatal(err) + } + if canceledAt.IsZero() { + t.Fatal("player never started") + } + if elapsed := time.Since(canceledAt); elapsed > time.Second { + t.Fatalf("canceled playback kept running for %v", elapsed) + } + }) + } +} + func summarizeReports(statuses []PlayerStatus) []timelineReport { reports := make([]timelineReport, 0, len(statuses)) for _, status := range statuses { diff --git a/internal/tui/player/video_output.go b/internal/tui/player/video_output.go index 2845b59..38bf325 100644 --- a/internal/tui/player/video_output.go +++ b/internal/tui/player/video_output.go @@ -77,14 +77,14 @@ func selectTerminalOutput(preference config.VideoOutput, help string, getenv fun // sixelSizeArgs bounds the pixel work without stretching the physical cells. // Both the pixel size and matching cell geometry must describe the same area. func sixelSizeArgs(limit, columns, rows, cellW, cellH int) []string { - if limit <= 0 { - return nil - } if cellW <= 0 || cellH <= 0 { cellW, cellH = 8, 16 } columns, rows = max(1, columns), max(2, rows) - width := min(columns*cellW, limit) + width := columns * cellW + if limit > 0 { + width = min(width, limit) + } cols := max(1, width/cellW) return []string{ fmt.Sprintf("--vo-sixel-width=%d", width), @@ -94,3 +94,33 @@ func sixelSizeArgs(limit, columns, rows, cellW, cellH int) []string { fmt.Sprintf("--vo-sixel-left=%d", (columns-cols)/2+1), } } + +// Terminal VOs copy their private options at initialization. Updating option +// properties alone succeeds but leaves the active output using its old size. +// Recreate the VO after applying the complete geometry, preserving pause/time. +func resizeVideoOutput(output config.VideoOutput, args []string, graph string, call func(...any) error) error { + for _, arg := range args { + option, value, _ := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + if err := call("set_property", option, value); err != nil { + return fmt.Errorf("resize terminal video: %w", err) + } + } + // Assigning the same VO is optimized away by mpv; use null as a temporary + // output so no window can open and the next assignment reloads the options. + for _, vo := range []string{"null", string(output)} { + if err := call("set_property", "vo", vo); err != nil { + return fmt.Errorf("reconfigure terminal video: %w", err) + } + } + // mpv's VO replacement reconnects native video tracks, but not a video + // stream produced by lavfi-complex. Reapply that graph to reconnect music + // visualizers too. Whitespace forces an update before restoring its value. + if graph != "" { + for _, value := range []string{graph + " ", graph} { + if err := call("set_property", "lavfi-complex", value); err != nil { + return fmt.Errorf("reconnect terminal visualizer: %w", err) + } + } + } + return nil +} diff --git a/internal/tui/player/visualizer_test.go b/internal/tui/player/visualizer_test.go index 1e939c2..e81f79a 100644 --- a/internal/tui/player/visualizer_test.go +++ b/internal/tui/player/visualizer_test.go @@ -30,8 +30,8 @@ func TestSixelSizeCapAndCentering(t *testing.T) { t.Fatalf("missing %s: %v", want, args) } } - if len(sixelSizeArgs(0, 200, 50, 10, 20)) != 0 { - t.Fatal("automatic sizing was capped") + if args := sixelSizeArgs(0, 200, 50, 10, 20); !slices.Contains(args, "--vo-sixel-width=2000") { + t.Fatalf("uncapped playback must still fit the pane: %v", args) } if args := sixelSizeArgs(640, 40, 25, 8, 16); !slices.Contains(args, "--vo-sixel-width=320") { t.Fatalf("sizing exceeds a narrow terminal: %v", args) From 5203f554f306544978b6eefa9dde7570ac7acba8 Mon Sep 17 00:00:00 2001 From: mr-pmillz <58376398+mr-pmillz@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:28:27 -0400 Subject: [PATCH 21/21] fix: harden Plex authentication, async state, and playback Address adversarial review findings in credential handling, URL validation, configuration persistence, caches, malformed metadata, asynchronous views, and subprocess lifetimes. Add regression coverage, update vulnerable dependencies, and document validation and remaining upstream documentation advisories. --- cmd/devices.go | 16 +- cmd/history.go | 21 ++- cmd/homeusers.go | 8 +- cmd/root.go | 19 +- cmd/root_test.go | 50 +++++ cmd/tasks.go | 42 +++-- cmd/tasks_test.go | 37 ++++ cmd/users.go | 8 +- docs/adversarial-review.md | 123 ++++++++++++ docs/package-lock.json | 118 ++++++------ docs/package.json | 11 +- go.mod | 10 +- go.sum | 20 +- internal/cache/cache.go | 56 +++--- internal/cache/cache_test.go | 104 ++++++++++ internal/commands/wrapper.go | 8 +- internal/commands/wrapper_test.go | 69 +++++++ internal/config/auth.go | 5 + internal/config/auth_test.go | 19 +- internal/config/config.go | 64 ++++++- internal/config/config_test.go | 116 ++++++++++++ internal/config/server_target.go | 18 +- internal/mpvsetup/check.go | 5 +- internal/mpvsetup/check_integration_test.go | 50 +++++ internal/plex/artwork_auth_test.go | 2 +- internal/plex/client.go | 41 ++-- internal/plex/client_test.go | 85 +++++++++ internal/plex/http.go | 75 ++++++++ internal/plex/http_test.go | 134 +++++++++++++ internal/plex/loader.go | 45 +++-- internal/plex/loader_test.go | 75 ++++++++ internal/plex/media.go | 22 ++- internal/plex/media_test.go | 34 ++++ internal/plex/music.go | 8 +- internal/plex/pagination.go | 33 +++- internal/plex/pagination_test.go | 93 +++++++++ internal/plex/servers.go | 10 +- internal/plex/servers_test.go | 12 +- internal/plex/store.go | 86 +++++---- internal/plex/store_test.go | 116 ++++++++++++ internal/presenters/server.go | 2 +- internal/presenters/server_test.go | 33 ++++ internal/search/index.go | 49 +++-- internal/search/index_test.go | 108 +++++++++++ internal/termgraphics/pane.go | 4 +- internal/termgraphics/terminal.go | 8 +- internal/tui/artwork/decode.go | 27 +++ internal/tui/artwork/decode_test.go | 49 +++++ internal/tui/player/actions.go | 32 ++-- internal/tui/player/actions_queue_test.go | 2 +- internal/tui/player/actions_test.go | 33 ++++ internal/tui/player/manager.go | 6 +- internal/tui/player/manager_test.go | 31 +++ internal/tui/player/terminal.go | 14 +- internal/tui/player/video_output.go | 4 +- .../player/video_output_integration_test.go | 52 +++++ internal/tui/tui.go | 178 +++++++++++++----- internal/tui/tui_test.go | 77 ++++++++ internal/tui/view/detail/detail_shared.go | 7 +- internal/tui/view/friends.go | 3 +- internal/tui/view/friends_test.go | 6 +- internal/tui/view/history.go | 78 +++++--- internal/tui/view/history_test.go | 2 +- internal/tui/view/home.go | 48 +++-- internal/tui/view/libraries.go | 43 +++-- internal/tui/view/libraries_test.go | 2 +- internal/tui/view/requests.go | 44 +++++ internal/tui/view/requests_test.go | 78 ++++++++ internal/tui/view/search_status.go | 3 +- internal/tui/view/sessions.go | 64 ++++--- internal/tui/view/sessions_test.go | 2 +- internal/tui/view/tasks.go | 71 ++++--- internal/tui/view/tasks_test.go | 2 +- internal/tui/widget/poster/grid.go | 3 +- internal/tui/widget/poster/item.go | 14 +- internal/tui/widget/poster/item_test.go | 29 +++ internal/tui/widget/poster/list.go | 3 +- internal/tui/widget/poster/list_test.go | 22 +++ 78 files changed, 2619 insertions(+), 482 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 cmd/tasks_test.go create mode 100644 docs/adversarial-review.md create mode 100644 internal/cache/cache_test.go create mode 100644 internal/commands/wrapper_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/mpvsetup/check_integration_test.go create mode 100644 internal/plex/client_test.go create mode 100644 internal/plex/http.go create mode 100644 internal/plex/http_test.go create mode 100644 internal/plex/loader_test.go create mode 100644 internal/plex/media_test.go create mode 100644 internal/plex/pagination_test.go create mode 100644 internal/plex/store_test.go create mode 100644 internal/presenters/server_test.go create mode 100644 internal/search/index_test.go create mode 100644 internal/tui/artwork/decode.go create mode 100644 internal/tui/artwork/decode_test.go create mode 100644 internal/tui/player/video_output_integration_test.go create mode 100644 internal/tui/view/requests.go create mode 100644 internal/tui/view/requests_test.go create mode 100644 internal/tui/widget/poster/item_test.go diff --git a/cmd/devices.go b/cmd/devices.go index 624a2b2..bf5e2f6 100644 --- a/cmd/devices.go +++ b/cmd/devices.go @@ -19,9 +19,10 @@ var deviceCmd = &cobra.Command{ } var deviceListCmd = &cobra.Command{ - Use: "list", - Short: "List all devices associated with this account", - RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + Use: "list", + Short: "List all devices associated with this account", + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: commands.RunWithClient(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { res, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{}) if err != nil { return err @@ -39,10 +40,11 @@ var deviceListCmd = &cobra.Command{ } var deviceShowCmd = &cobra.Command{ - Use: "show [device_id]", - Short: "Show detailed information for a device", - Args: cobra.ExactArgs(1), - RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + Use: "show [device_id]", + Short: "Show detailed information for a device", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: commands.RunWithClient(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { deviceID := args[0] res, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{}) if err != nil { diff --git a/cmd/history.go b/cmd/history.go index 57927b9..78a3157 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -36,10 +36,13 @@ var historyCmd = &cobra.Command{ } userMap := make(map[int64]string) - uRes, err := client.SDK.Users.GetUsers(ctx, operations.GetUsersRequest{}) - if err == nil && uRes.Object != nil && uRes.Object.MediaContainer != nil { - for _, u := range uRes.Object.MediaContainer.User { - userMap[u.ID] = u.Title + account, accountErr := plex.NewAccountClient() + if accountErr == nil { + uRes, err := account.SDK.Users.GetUsers(ctx, operations.GetUsersRequest{}) + if err == nil && uRes.Object != nil && uRes.Object.MediaContainer != nil { + for _, u := range uRes.Object.MediaContainer.User { + userMap[u.ID] = u.Title + } } } @@ -54,10 +57,12 @@ var historyCmd = &cobra.Command{ } deviceMap := make(map[string]string) - dRes, err := client.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{}) - if err == nil { - for _, d := range dRes.PlexDevices { - deviceMap[d.ClientIdentifier] = d.Name + if accountErr == nil { + dRes, err := account.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{}) + if err == nil { + for _, d := range dRes.PlexDevices { + deviceMap[d.ClientIdentifier] = d.Name + } } } diff --git a/cmd/homeusers.go b/cmd/homeusers.go index e251f4a..84063c5 100644 --- a/cmd/homeusers.go +++ b/cmd/homeusers.go @@ -8,6 +8,7 @@ import ( "github.com/ygelfand/plexctl/internal/commands" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/presenters" + "github.com/ygelfand/plexctl/internal/ui" ) var homeUsersCmd = &cobra.Command{ @@ -17,9 +18,10 @@ var homeUsersCmd = &cobra.Command{ } var homeUsersListCmd = &cobra.Command{ - Use: "list", - Short: "List all users in the Plex Home", - RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + Use: "list", + Short: "List all users in the Plex Home", + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: commands.RunWithClient(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { res, err := client.SDK.HomeUsers.GetHomeUsers(ctx) if err != nil { return err diff --git a/cmd/root.go b/cmd/root.go index ca8ec80..a679368 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -120,9 +121,12 @@ func initConfig() { // Explicitly bind env vars that don't have corresponding flags cobra.CheckErr(viper.BindEnv("token")) - if err := viper.ReadInConfig(); err == nil { - cfg.ConfigPath = viper.ConfigFileUsed() + path, err := readConfigFile(viper.GetViper(), cfgFile != "") + if err != nil { + ui.RenderError(err) + os.Exit(1) } + cfg.ConfigPath = path // Unmarshal the loaded config into our struct if err := viper.Unmarshal(cfg); err != nil { @@ -158,3 +162,14 @@ func initConfig() { // Sync back to viper for parts that still use it viper.Set("output", cfg.OutputFormat) } + +func readConfigFile(v *viper.Viper, explicit bool) (string, error) { + if err := v.ReadInConfig(); err != nil { + var missing viper.ConfigFileNotFoundError + if !explicit && errors.As(err, &missing) { + return "", nil + } + return "", fmt.Errorf("read configuration: %w", err) + } + return v.ConfigFileUsed(), nil +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..3fc4778 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +func TestReadConfigFileReportsErrors(t *testing.T) { + for _, tt := range []struct { + name string + contents string + create bool + explicit bool + wantErr bool + }{ + {name: "optional missing"}, + {name: "explicit missing", explicit: true, wantErr: true}, + {name: "optional malformed", contents: "servers: [", create: true, wantErr: true}, + {name: "explicit malformed", contents: "servers: [", create: true, explicit: true, wantErr: true}, + {name: "valid", contents: "default_to_tui: false\n", create: true}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".plexctl.yaml") + if tt.create { + if err := os.WriteFile(path, []byte(tt.contents), 0o600); err != nil { + t.Fatal(err) + } + } + v := viper.New() + v.SetConfigType("yaml") + if tt.explicit { + v.SetConfigFile(path) + } else { + v.AddConfigPath(dir) + v.SetConfigName(".plexctl") + } + gotPath, err := readConfigFile(v, tt.explicit) + if (err != nil) != tt.wantErr { + t.Fatalf("readConfigFile error = %v; want error %v", err, tt.wantErr) + } + if tt.create && !tt.wantErr && gotPath != path { + t.Fatalf("config path = %q, want %q", gotPath, path) + } + }) + } +} diff --git a/cmd/tasks.go b/cmd/tasks.go index 3b95f2b..73e3df3 100644 --- a/cmd/tasks.go +++ b/cmd/tasks.go @@ -84,15 +84,7 @@ var tasksStartCmd = &cobra.Command{ slog.Error("SDK: Failed to get tasks for selection", "error", err) return err } - var options []struct{ Title, Desc, Value string } - for _, t := range res.Object.ButlerTasks.ButlerTask { - options = append(options, struct{ Title, Desc, Value string }{ - Title: *t.Title, - Desc: *t.Description, - Value: *t.Name, - }) - } - taskName, err = ui.SelectOption("Select a task to start", options) + taskName, err = ui.SelectOption("Select a task to start", taskOptions(res)) if err != nil { return err } @@ -127,15 +119,7 @@ var tasksStopCmd = &cobra.Command{ slog.Error("SDK: Failed to get tasks for stop selection", "error", err) return err } - var options []struct{ Title, Desc, Value string } - for _, t := range res.Object.ButlerTasks.ButlerTask { - options = append(options, struct{ Title, Desc, Value string }{ - Title: *t.Title, - Desc: *t.Description, - Value: *t.Name, - }) - } - taskName, err = ui.SelectOption("Select a task to stop", options) + taskName, err = ui.SelectOption("Select a task to stop", taskOptions(res)) if err != nil { return err } @@ -155,6 +139,28 @@ var tasksStopCmd = &cobra.Command{ }), } +// taskOptions accepts sparse Plex responses and never offers an unusable task ID. +func taskOptions(res *operations.GetTasksResponse) []struct{ Title, Desc, Value string } { + if res == nil || res.Object == nil || res.Object.ButlerTasks == nil { + return nil + } + var options []struct{ Title, Desc, Value string } + for _, task := range res.Object.ButlerTasks.ButlerTask { + name := ui.PtrToString(task.Name) + if name == "" { + continue + } + title := ui.PtrToString(task.Title) + if title == "" { + title = name + } + options = append(options, struct{ Title, Desc, Value string }{ + Title: title, Desc: ui.PtrToString(task.Description), Value: name, + }) + } + return options +} + func init() { rootCmd.AddCommand(tasksCmd) tasksCmd.AddCommand(tasksListCmd) diff --git a/cmd/tasks_test.go b/cmd/tasks_test.go new file mode 100644 index 0000000..5d4b0ea --- /dev/null +++ b/cmd/tasks_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "testing" + + "github.com/LukeHagar/plexgo/models/operations" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestTaskOptionsHandlesSparseResponses(t *testing.T) { + for _, tt := range []struct { + name string + res *operations.GetTasksResponse + }{ + {"missing response", nil}, + {"missing object", &operations.GetTasksResponse{}}, + {"missing task container", &operations.GetTasksResponse{Object: &operations.GetTasksResponseBody{}}}, + } { + t.Run(tt.name, func(t *testing.T) { + if options := taskOptions(tt.res); len(options) != 0 { + t.Fatalf("unexpected options: %v", options) + } + }) + } + res := &operations.GetTasksResponse{Object: &operations.GetTasksResponseBody{ + ButlerTasks: &operations.ButlerTasks{ButlerTask: []operations.GetTasksButlerTask{ + {}, + {Name: ui.Ptr("")}, + {Name: ui.Ptr("CleanOldBundles")}, + {Name: ui.Ptr("RefreshLibraries"), Title: ui.Ptr("Refresh"), Description: ui.Ptr("Refresh metadata")}, + }}, + }} + options := taskOptions(res) + if len(options) != 2 || options[0].Title != "CleanOldBundles" || options[0].Desc != "" || options[1].Title != "Refresh" || options[1].Value != "RefreshLibraries" { + t.Fatalf("invalid task options: %#v", options) + } +} diff --git a/cmd/users.go b/cmd/users.go index 6c96926..4f432fb 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -9,6 +9,7 @@ import ( "github.com/ygelfand/plexctl/internal/commands" "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/presenters" + "github.com/ygelfand/plexctl/internal/ui" ) var usersCmd = &cobra.Command{ @@ -18,9 +19,10 @@ var usersCmd = &cobra.Command{ } var usersListCmd = &cobra.Command{ - Use: "list", - Short: "List all users", - RunE: commands.RunWithServer(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { + Use: "list", + Short: "List all users", + Annotations: map[string]string{ui.AnnotationSkipServerCheck: "true"}, + RunE: commands.RunWithClient(func(ctx context.Context, client *plex.Client, cmd *cobra.Command, args []string, opts *commands.PlexCtlOptions) error { res, err := client.SDK.Users.GetUsers(ctx, operations.GetUsersRequest{}) if err != nil { return err diff --git a/docs/adversarial-review.md b/docs/adversarial-review.md new file mode 100644 index 0000000..0921b01 --- /dev/null +++ b/docs/adversarial-review.md @@ -0,0 +1,123 @@ +# Adversarial review — 2026-10-17 + +Reviewed the Go CLI/TUI, Plex authentication and HTTP clients, caches, background +requests, playback processes, mpv setup, and the documentation application's +dependency tree. Changes remain in the working tree. + +## Fixed findings + +| Priority | Finding and trigger | Result | +| --- | --- | --- | +| P1 | Verbose HTTP dumps included Plex tokens, Home PINs, response credentials, and URL secrets. Device JSON/YAML output also included resource tokens. | HTTP diagnostics retain only method, safe URL, status, and timing. Device output strips credentials without modifying the original objects. | +| P1 | Config writes used permissive permissions and could truncate credentials on failure. JSON persistence omitted resource tokens and did not consistently preserve field names. | Saves use a private temporary file and atomic replacement. YAML, JSON, and TOML round-trip credentials and library settings. | +| P1 | A server could redirect authenticated HTTP requests to another origin. A malformed media/subtitle/image path could change the URL authority when concatenated with the server URL. | Authenticated HTTP clients enforce the original origin. Parsed media paths preserve the configured destination and replace metadata-supplied token parameters. Artwork credentials travel in headers. | +| P1 | A saved shared server could receive a broad account token after the account changed; connection probes sent credentials before verifying which server occupied a private address. | Missing scoped shared-server credentials fail closed. Public identity probing precedes authenticated access checks. | +| P1 | History reused the first authenticated client after a profile switch. Background indexing could fetch and save data under different profiles. Account-only commands used server resource credentials. | Stores, indexes, loaders, and view requests capture their originating identity. Account and server clients use separate credentials; caches include endpoint and credential scope. | +| P1 | Sessions, Tasks, and Libraries all returned indistinguishable row slices; a late reply could populate a different tab and supply the wrong identifier to an action. | Results identify their originating view and refresh generation. The controller delivers results to that view even when it is inactive. Replaced views cannot consume old results. | +| P1 | Tiny compressed artwork with huge dimensions, oversized downloads, or extreme watched offsets could cause excessive allocation. | Artwork downloads and decoded dimensions are bounded. Poster progress bars clamp metadata offsets before calculating their size. | +| P2 | Terminal mpv inherited a two-minute request deadline, terminating long playback. | Playback retains cancellation for stop/replacement; metadata and startup have independent deadlines that do not limit running playback. | +| P2 | An exited mpv/tmux wrapper could leave a child holding stdout, hanging a supposedly timed probe. | Probe subprocesses use bounded pipe waits. Subprocess regressions cover inherited stdout. | +| P2 | History workers mutated the UI cursor, repeated pending pages, skipped days on errors, and overlapped inclusive day boundaries. User-picker, reload, and delayed navigation results could outlive their originating profile. | Workers return immutable page results. Requests carry generations, failed pages retain their cursor, day windows do not overlap, and stale switch/reload/navigation results are ignored. | +| P2 | Missing metadata containers, missing season keys, and sparse butler task fields could panic. Failed index child requests could silently replace a valid index with partial data. | Responses are validated, unavailable task identifiers are excluded, and failed reindexing preserves the previous index. | +| P2 | Cache initialization raced and ignored subsequent cache directories. Binary image data was inserted as invalid raw JSON. Cache write failures could discard otherwise successful network results. | Managers are synchronized and keyed by directory. Binary values round-trip through JSON encoding. Optional cache failures preserve fetched results. | +| P2 | Pagination accepted invalid/overflowing offsets, repeated non-advancing pages, and premature empty pages. | Invalid offsets and incomplete/repeated listings return errors; cancellation stops the walker. | +| P2 | Malformed or explicitly missing config files were silently ignored. A missing configured server entry was treated as usable. | Configuration errors are surfaced and server availability requires an actual endpoint. | +| P2 | A queued poster selection indexed a list after a refresh changed it. | Selection commands capture metadata before dispatch; title truncation preserves Unicode display widths. | + +Regression coverage is in the adjacent `*_test.go` files, including local HTTP +fixtures, stale-message delivery, profile changes, binary image caching, +malformed metadata, virtual-clock playback, and subprocess pipe inheritance. + +## Go dependency findings + +The initial `govulncheck` scan found no vulnerable imported packages or reachable +symbols, but reported 13 advisories in unused packages of `golang.org/x/image` +and `golang.org/x/net`. Both modules were updated to the current stable versions +verified through the Go module proxy and upstream commit histories: + +| Module | Before | After | +| --- | --- | --- | +| `golang.org/x/image` | v0.38.0 | v0.47.0 | +| `golang.org/x/net` | v0.59.0 | v0.61.0 | +| `golang.org/x/sys` | v0.48.0 | v0.49.0 | +| `golang.org/x/term` | v0.46.0 | v0.47.0 | +| `golang.org/x/crypto` | v0.57.0 | v0.58.0 | + +The latter three updates follow the upstream module requirements. The project's +Go requirement remains 1.27.2. The directly used `image/draw` and `net/html` +packages are unchanged between the old and new releases. The final scan reports +no vulnerabilities, including at module level. + +## Documentation dependencies + +The documentation app exports static files to GitHub Pages. Compatible fixes +were applied without changing the Nextra framework version: + +| Package | Before | After | +| --- | --- | --- | +| Next and its two ESLint companions | 16.4.0 | 16.4.2 | +| `@xmldom/xmldom` | 0.9.10 | 0.9.12 | +| `source-map-js` | 1.2.1 | 1.2.2 | +| `ajv` | 6.12.6 | 6.15.0 | + +The xmldom override applies only to `speech-rule-engine`, whose latest stable +release pins the vulnerable version. Other transitive updates satisfy existing +parent ranges. Next and its ESLint companions stay aligned. The +[16.4.1 security fixes](https://github.com/vercel/next.js/releases/tag/v16.4.1) +are included in [16.4.2](https://github.com/vercel/next.js/releases/tag/v16.4.2); +the affected server and image endpoints are not deployed by this static export. + +The final npm audits still exit with status 1: + +| Audit | Before | After | +| --- | --- | --- | +| Full dependency tree | 17: 8 high, 2 moderate, 7 low | 13: 6 high, 7 low | +| Production dependencies | 14: 6 high, 1 moderate, 7 low | 11: 4 high, 7 low | + +Those are propagated package counts for two remaining upstream advisories: + +- [braces: GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). + Attacker-controlled nested glob patterns can exhaust the stack. No patched + release exists. Here, Nextra and Next ESLint reach it through fast-glob and + micromatch using repository paths and build/lint configuration. Static-site + visitors cannot supply these patterns. +- [KaTeX: GHSA-238p-pmpm-9mq7](https://github.com/advisories/GHSA-238p-pmpm-9mq7). + The bypass requires existing prototype pollution or attacker-controlled + option prototypes, together with attacker-controlled math expressions. + KaTeX does not introduce the pollution. Version 0.18.2 fixes it, but lies + outside the current parents' 0.16 ranges; no compatible Nextra update exists. + This app renders repository-owned MDX, with no identified input path for + untrusted expressions/options or prototype pollution. + +Neither remaining advisory has a demonstrated exploit path in this application. +They remain recorded rather than being hidden by audit exceptions or a forced +framework downgrade. Reassess them if the site accepts untrusted content or +changes to a server deployment. + +## Validation + +Passed: + +- `go mod verify` and `go mod tidy -diff`. +- `go build ./...`. +- `go test -race -shuffle=on -count=1 -tags=integration ./...`. +- Both `golangci-lint run` and `golangci-lint run --build-tags integration`. +- `govulncheck ./...`, using v1.8.0 built with Go 1.27.2. +- Windows amd64 and macOS arm64 builds with CGO disabled. +- Focused race and lint checks for the final poster bounds fix. +- Documentation: clean `npm ci`, valid `npm ls`, and `npm run build` producing + 49 static pages, using npm 11.21.1. +- Documentation `npm run lint`: zero errors, one existing `no-img-element` + warning in `app/layout.jsx:30`. +- `git diff --check`. + +Security scanner diagnostics were traced through their call sites. Remaining +heuristic reports concern graphics IDs that are not secrets, non-cryptographic +cache hashes, bounded numeric conversions, locally generated paths, structured +subprocess arguments, and intentionally readable public build outputs. They do +not represent demonstrated exploits in these uses; no blanket suppressions were +added. + +Runtime integration tests ran on Linux. Windows and macOS were compile checks. +Authentication and HTTP regressions use local server fixtures; no live +authenticated Plex account was used for validation. diff --git a/docs/package-lock.json b/docs/package-lock.json index dfa9c26..f187dab 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "next": "^16.1.6", + "next": "^16.4.2", "nextra": "^4.6.1", "nextra-theme-docs": "^4.6.1", "react": "^19.2.4", @@ -18,9 +18,9 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@next/eslint-plugin-next": "^16.1.6", + "@next/eslint-plugin-next": "^16.4.2", "eslint": "^9.39.2", - "eslint-config-next": "^16.1.6", + "eslint-config-next": "^16.4.2", "eslint-plugin-react": "^7.37.5" } }, @@ -1602,15 +1602,15 @@ } }, "node_modules/@next/env": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", - "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz", + "integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz", - "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.2.tgz", + "integrity": "sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1636,9 +1636,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", - "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz", + "integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==", "cpu": [ "arm64" ], @@ -1652,9 +1652,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", - "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz", + "integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==", "cpu": [ "x64" ], @@ -1668,9 +1668,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", - "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz", + "integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==", "cpu": [ "arm64" ], @@ -1687,9 +1687,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", - "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz", + "integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==", "cpu": [ "arm64" ], @@ -1706,9 +1706,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", - "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz", + "integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==", "cpu": [ "x64" ], @@ -1725,9 +1725,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", - "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz", + "integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==", "cpu": [ "x64" ], @@ -1744,9 +1744,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", - "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz", + "integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==", "cpu": [ "arm64" ], @@ -1760,9 +1760,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", - "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz", + "integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==", "cpu": [ "x64" ], @@ -2982,9 +2982,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "license": "MIT", "engines": { "node": ">=14.6" @@ -3012,9 +3012,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4729,13 +4729,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz", - "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.2.tgz", + "integrity": "sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.4.0", + "@next/eslint-plugin-next": "16.4.2", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -8305,12 +8305,12 @@ } }, "node_modules/next": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", - "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz", + "integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==", "license": "MIT", "dependencies": { - "@next/env": "16.4.0", + "@next/env": "16.4.2", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -8324,15 +8324,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.4.0", - "@next/swc-darwin-x64": "16.4.0", - "@next/swc-linux-arm64-gnu": "16.4.0", - "@next/swc-linux-arm64-musl": "16.4.0", - "@next/swc-linux-x64-gnu": "16.4.0", - "@next/swc-linux-x64-musl": "16.4.0", - "@next/swc-win32-arm64-msvc": "16.4.0", - "@next/swc-win32-x64-msvc": "16.4.0", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.2", + "@next/swc-darwin-x64": "16.4.2", + "@next/swc-linux-arm64-gnu": "16.4.2", + "@next/swc-linux-arm64-musl": "16.4.2", + "@next/swc-linux-x64-gnu": "16.4.2", + "@next/swc-linux-x64-musl": "16.4.2", + "@next/swc-win32-arm64-msvc": "16.4.2", + "@next/swc-win32-x64-msvc": "16.4.2", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -9972,9 +9972,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/docs/package.json b/docs/package.json index 426ee54..8d2f0c7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,7 +14,7 @@ "author": "", "license": "ISC", "dependencies": { - "next": "^16.1.6", + "next": "^16.4.2", "nextra": "^4.6.1", "nextra-theme-docs": "^4.6.1", "react": "^19.2.4", @@ -23,9 +23,14 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@next/eslint-plugin-next": "^16.1.6", + "@next/eslint-plugin-next": "^16.4.2", "eslint": "^9.39.2", - "eslint-config-next": "^16.1.6", + "eslint-config-next": "^16.4.2", "eslint-plugin-react": "^7.37.5" + }, + "overrides": { + "speech-rule-engine": { + "@xmldom/xmldom": "^0.9.12" + } } } diff --git a/go.mod b/go.mod index a98d64f..71c59d2 100644 --- a/go.mod +++ b/go.mod @@ -23,10 +23,10 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 go.dalton.dog/bubbleup v1.3.0 - golang.org/x/image v0.38.0 - golang.org/x/net v0.59.0 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 + golang.org/x/image v0.47.0 + golang.org/x/net v0.61.0 + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce gopkg.in/yaml.v3 v3.0.1 @@ -87,7 +87,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/crypto v0.57.0 // indirect + golang.org/x/crypto v0.58.0 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/tools v0.51.0 // indirect diff --git a/go.sum b/go.sum index 80f3330..9d5a2f4 100644 --- a/go.sum +++ b/go.sum @@ -176,25 +176,25 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 57be136..1f67370 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -8,7 +8,9 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "reflect" + "sync" "time" "github.com/peterbourgon/diskv" @@ -24,15 +26,24 @@ type Manager struct { dv *diskv.Diskv } -var globalManager *Manager +var ( + managersMu sync.Mutex + managers = make(map[string]*Manager) +) // Get returns the global cache manager instance initialized with the given path func Get(path string) (*Manager, error) { - if globalManager != nil { - return globalManager, nil + path, err := filepath.Abs(path) + if err != nil { + return nil, err + } + managersMu.Lock() + defer managersMu.Unlock() + if manager := managers[path]; manager != nil { + return manager, nil } - if err := os.MkdirAll(path, 0o755); err != nil { + if err := os.MkdirAll(path, 0o700); err != nil { return nil, err } @@ -44,10 +55,13 @@ func Get(path string) (*Manager, error) { BasePath: path, Transform: flatTransform, CacheSizeMax: 1024 * 1024, // 1MB + PathPerm: 0o700, + FilePerm: 0o600, }) - globalManager = &Manager{dv: dv} - return globalManager, nil + manager := &Manager{dv: dv} + managers[path] = manager + return manager, nil } // HashKey converts a potentially unsafe string into a safe MD5 hash for disk storage @@ -83,16 +97,11 @@ func (m *Manager) Set(key string, val any, ttl time.Duration) error { } safeKey := m.HashKey(key) slog.Log(context.Background(), config.LevelTrace, "Cache: SET", "key", key, "safeKey", safeKey, "ttl", ttl) - var data []byte - var err error - - if b, ok := val.([]byte); ok { - data = b - } else { - data, err = json.Marshal(val) - if err != nil { - return err - } + // Marshal binary values too: encoding/json stores []byte as base64 instead + // of treating image bytes as raw JSON in the cache envelope. + data, err := json.Marshal(val) + if err != nil { + return err } var expiresAt int64 @@ -130,23 +139,18 @@ func (m *Manager) Get(key string, val any) error { return err } - if entry.ExpiresAt > 0 && time.Now().Unix() > entry.ExpiresAt { + if entry.ExpiresAt > 0 && time.Now().Unix() >= entry.ExpiresAt { slog.Log(context.Background(), config.LevelTrace, "Cache: EXPIRED", "key", key, "safeKey", safeKey) _ = m.dv.Erase(safeKey) return fmt.Errorf("cache entry expired") } - if b, ok := val.(*[]byte); ok { - *b = entry.Value - return nil - } - return json.Unmarshal(entry.Value, val) } // WithCache is a helper that tries to get data from cache first, otherwise calls the fetcher func WithCache[T any](m *Manager, key string, ttl time.Duration, val *T, fetcher func() (*T, error)) error { - if ttl > 0 { + if m != nil && ttl > 0 { if err := m.Get(key, val); err == nil { return nil } @@ -158,8 +162,10 @@ func WithCache[T any](m *Manager, key string, ttl time.Duration, val *T, fetcher if fetched != nil { *val = *fetched - if ttl > 0 { - return m.Set(key, fetched, ttl) + if m != nil && ttl > 0 { + if err := m.Set(key, fetched, ttl); err != nil { + slog.Warn("Cache: failed to save fetched data", "error", err) + } } } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..0836faa --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,104 @@ +package cache + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestGetConcurrentAndSeparateDirectories(t *testing.T) { + dir := t.TempDir() + const workers = 24 + managers := make([]*Manager, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for i := range workers { + wg.Go(func() { + manager, err := Get(filepath.Join(dir, fmt.Sprint(i%2))) + managers[i] = manager + errs <- err + }) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + for i, manager := range managers { + if manager != managers[i%2] { + t.Fatal("concurrent cache initialization returned different managers for one directory") + } + } + if err := managers[0].Set("same-key", "first directory", time.Hour); err != nil { + t.Fatal(err) + } + var value string + if err := managers[1].Get("same-key", &value); err == nil { + t.Fatalf("different cache directory exposed %q", value) + } +} + +func TestBinaryRoundTrip(t *testing.T) { + m, err := Get(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name string + data []byte + }{ + {"image", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0xff}}, + {"JSON-looking bytes", []byte(`{"image":"bytes"}`)}, + {"empty", []byte{}}, + {"nil", nil}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := m.Set(tt.name, tt.data, time.Hour); err != nil { + t.Fatalf("cache binary value: %v", err) + } + var got []byte + if err := m.Get(tt.name, &got); err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, tt.data) { + t.Fatalf("binary value changed: %x != %x", got, tt.data) + } + }) + } +} + +func TestWithCachePreservesFetchedValueOnWriteFailure(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cache") + m, err := Get(dir) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + want := "successful network response" + var got string + if err := WithCache(m, "key", time.Hour, &got, func() (*string, error) { return &want, nil }); err != nil { + t.Fatalf("cache failure replaced successful response: %v", err) + } + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + if err := WithCache[string](nil, "key", time.Hour, &got, func() (*string, error) { return &want, nil }); err != nil { + t.Fatal(err) + } + fetchErr := errors.New("fetch failed") + if err := WithCache[string](nil, "key", time.Hour, &got, func() (*string, error) { return nil, fetchErr }); !errors.Is(err, fetchErr) { + t.Fatalf("fetch error was hidden: %v", err) + } +} diff --git a/internal/commands/wrapper.go b/internal/commands/wrapper.go index 38b2120..6055c86 100644 --- a/internal/commands/wrapper.go +++ b/internal/commands/wrapper.go @@ -18,6 +18,10 @@ type RunnerFunc func(ctx context.Context, client *plex.Client, cmd *cobra.Comman // RunWithClient wraps a cobra command RunE function to inject a configured Plex client. // This is for commands that only need a token (like login or discovery). func RunWithClient(runner RunnerFunc) func(cmd *cobra.Command, args []string) error { + return runWithClient(plex.NewAccountClient, runner) +} + +func runWithClient(newClient func() (*plex.Client, error), runner RunnerFunc) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { opts := &PlexCtlOptions{ OutputFormat: viper.GetString("output"), @@ -28,7 +32,7 @@ func RunWithClient(runner RunnerFunc) func(cmd *cobra.Command, args []string) er All: viper.GetBool("all"), } - client, err := plex.NewClient() + client, err := newClient() if err != nil { return err } @@ -39,7 +43,7 @@ func RunWithClient(runner RunnerFunc) func(cmd *cobra.Command, args []string) er // RunWithServer wraps RunWithClient but ensures a default server is selected first. // This is for commands that interact with specific media or server library sections. func RunWithServer(runner RunnerFunc) func(cmd *cobra.Command, args []string) error { - return RunWithClient(runner) + return runWithClient(plex.NewClient, runner) } // EnsureActiveServer checks if a server is configured and triggers discovery if not diff --git a/internal/commands/wrapper_test.go b/internal/commands/wrapper_test.go new file mode 100644 index 0000000..d4d694a --- /dev/null +++ b/internal/commands/wrapper_test.go @@ -0,0 +1,69 @@ +package commands + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/plex" +) + +type commandTransport func(*http.Request) (*http.Response, error) + +func (f commandTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestCommandClientsSeparateAccountAndServerTokens(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.Token = "account-token" + cfg.HomeUser = config.HomeUser{} + cfg.DefaultServer = "shared" + cfg.Servers = map[string]config.Server{"shared": { + URL: "https://friend.invalid", Shared: true, + AccessToken: "resource-token", TokenSource: cfg.TokenSource(), + }} + base := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = base }) + requests := 0 + http.DefaultTransport = commandTransport(func(req *http.Request) (*http.Response, error) { + requests++ + body := `{"MediaContainer":{"Directory":[]}}` + want := "resource-token" + if req.URL.Host == "plex.tv" { + want = "account-token" + body = `{"users":[]}` + } else if req.URL.Host != "friend.invalid" { + t.Errorf("unexpected origin %q", req.URL.Host) + } + if got := req.Header.Get("X-Plex-Token"); got != want { + t.Errorf("wrong credential for %s", req.URL.Host) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + }) + account := RunWithClient(func(ctx context.Context, client *plex.Client, _ *cobra.Command, _ []string, _ *PlexCtlOptions) error { + _, err := client.SDK.HomeUsers.GetHomeUsers(ctx) + return err + }) + server := RunWithServer(func(ctx context.Context, client *plex.Client, _ *cobra.Command, _ []string, _ *PlexCtlOptions) error { + _, err := client.SDK.Library.GetSections(ctx) + return err + }) + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + if err := account(cmd, nil); err != nil { + t.Fatal(err) + } + if err := server(cmd, nil); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("got %d requests, want 2", requests) + } +} diff --git a/internal/config/auth.go b/internal/config/auth.go index 1507945..25f18b4 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -33,6 +33,11 @@ func (c *Config) ServerToken() string { if c.HasServerToken() { return c.Servers[c.DefaultServer].AccessToken } + if _, server, ok := c.GetActiveServer(); ok && server.Shared { + // A changed account/profile must rediscover its scoped resource token. + // Broad account credentials must never be sent to someone else's PMS. + return "" + } if token := os.Getenv("PLEXCTL_TOKEN"); token != "" { return token } diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go index cd9ea38..a469c9f 100644 --- a/internal/config/auth_test.go +++ b/internal/config/auth_test.go @@ -33,7 +33,7 @@ func TestResourceTokensStayWithTheirServerAndAccount(t *testing.T) { } namespace := cfg.MediaCacheNamespace() cfg.HomeUser.AuthToken = "different-profile" - if cfg.HasServerToken() || cfg.ServerToken() != "different-profile" || cfg.MediaCacheNamespace() == namespace { + if cfg.HasServerToken() || cfg.ServerToken() != "" || cfg.MediaCacheNamespace() == namespace { t.Fatal("a different Home profile reused the previous user's access or cache") } t.Setenv("PLEXCTL_TOKEN", "account") @@ -41,7 +41,22 @@ func TestResourceTokensStayWithTheirServerAndAccount(t *testing.T) { t.Fatal("resource token for the environment account was not selected") } t.Setenv("PLEXCTL_TOKEN", "different-account") - if cfg.ServerToken() != "different-account" { + if cfg.ServerToken() != "" { t.Fatal("resource token escaped its account scope") } } + +func TestSharedServerNeverFallsBackToAccountCredentials(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "environment-account") + cfg := Config{ + Token: "account", HomeUser: HomeUser{AuthToken: "home-account", AccessToken: "legacy-server-token"}, + DefaultServer: "friend", Servers: map[string]Server{"friend": {Shared: true}}, + } + if got := cfg.ServerTarget().Token; got != "" { + t.Fatal("shared server received fallback credentials") + } + cfg.Servers["friend"] = Server{Shared: true, AccessToken: "scoped-resource", TokenSource: cfg.TokenSource()} + if got := cfg.ServerTarget().Token; got != "scoped-resource" { + t.Fatal("shared server did not receive its matching resource token") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7135373..fc4af3a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,9 +6,11 @@ import ( "log/slog" "os" "path/filepath" + "strings" "sync" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) var ( @@ -220,9 +222,24 @@ func (c *Config) Enabled(level slog.Level) bool { // Save persists the current configuration to disk func (c *Config) Save() error { + // These structs use YAML tags for their persistence schema and JSON tags + // to redact runtime output. Convert them to plain maps so JSON/TOML config + // writers retain credentials and the same underscored keys as YAML. + data, err := yaml.Marshal(struct { + HomeUser HomeUser `yaml:"home_user"` + Servers map[string]Server `yaml:"servers"` + }{HomeUser: c.HomeUser, Servers: c.Servers}) + if err != nil { + return err + } + var persisted map[string]any + if err := yaml.Unmarshal(data, &persisted); err != nil { + return err + } + // Sync struct fields to viper before writing viper.Set("token", c.Token) - viper.Set("home_user", c.HomeUser) + viper.Set("home_user", persisted["home_user"]) viper.Set("output", c.OutputFormat) viper.Set("verbose", c.Verbosity) viper.Set("theme", c.Theme) @@ -241,18 +258,51 @@ func (c *Config) Save() error { viper.Set("music_visualizer", c.MusicVisualizer) viper.Set("cache_dir", c.CacheDir) viper.Set("default_server", c.DefaultServer) - viper.Set("servers", c.Servers) + viper.Set("servers", persisted["servers"]) - if c.ConfigPath != "" { - return viper.WriteConfig() + path := c.ConfigPath + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + path = filepath.Join(home, ".plexctl.yaml") } - home, err := os.UserHomeDir() + // Serialize separately, then atomically replace the old configuration. A + // private temporary file keeps credentials private even with a permissive + // umask or an existing configuration created by an older version. + settings := viper.New() + format := strings.TrimPrefix(filepath.Ext(path), ".") + if format == "" { + format = "yaml" + } + settings.SetConfigType(format) + if err := settings.MergeConfigMap(viper.AllSettings()); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".plexctl-config-*") if err != nil { return err } - c.ConfigPath = filepath.Join(home, ".plexctl.yaml") - return viper.WriteConfigAs(c.ConfigPath) + defer func() { _ = os.Remove(f.Name()) }() + if err := settings.WriteConfigTo(f); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(f.Name(), path); err != nil { + return err + } + c.ConfigPath = path + viper.SetConfigFile(path) + return nil } // GetActiveServer returns the configuration for the currently selected server. diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e40207c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestSaveKeepsCredentialsPrivate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits") + } + viper.Reset() + t.Cleanup(viper.Reset) + for _, name := range []string{"new", "previously public"} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if name == "previously public" { + if err := os.WriteFile(path, []byte("token: old\n"), 0644); err != nil { + t.Fatal(err) + } + } + cfg := Config{ConfigPath: path, Token: "private-account"} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("credential file permissions = %o, want 600", info.Mode().Perm()) + } + loaded := viper.New() + loaded.SetConfigFile(path) + if err := loaded.ReadInConfig(); err != nil || loaded.GetString("token") != cfg.Token { + t.Fatalf("saved credentials did not reload: %v", err) + } + }) + } +} + +func TestSaveFailurePreservesConfiguration(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + path := filepath.Join(t.TempDir(), "config.invalid") + const original = "existing configuration" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + cfg := Config{ConfigPath: path, Token: "replacement"} + if err := cfg.Save(); err == nil { + t.Fatal("unsupported configuration format was accepted") + } + got, err := os.ReadFile(path) + if err != nil || string(got) != original { + t.Fatalf("failed save damaged the previous file: %v", err) + } +} + +func TestSavePreservesCredentialsAcrossFormats(t *testing.T) { + for _, format := range []string{"yaml", "json", "toml"} { + t.Run(format, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + cfg := Config{ + ConfigPath: filepath.Join(t.TempDir(), "config."+format), Token: "account-token", + HomeUser: HomeUser{AuthToken: "profile-token", AccessToken: "profile-resource", ServerID: "shared"}, + DefaultServer: "shared", + Servers: map[string]Server{"shared": { + Name: "Shared library", URL: "https://plex.invalid", Shared: true, + AccessToken: "resource+&token", TokenSource: "source-fingerprint", + Libraries: LibraryConfig{ + Order: []string{"1"}, + Settings: map[string]LibraryOptions{"1": { + IconEmoji: "film", IconNF: "nf", IconASCII: "M", Hidden: true, ViewMode: ViewModeList, + }}, + }, + }}, + } + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + loaded := viper.New() + loaded.SetConfigFile(cfg.ConfigPath) + if err := loaded.ReadInConfig(); err != nil { + t.Fatal(err) + } + var got Config + if err := loaded.Unmarshal(&got); err != nil { + t.Fatal(err) + } + if got.Token != cfg.Token || got.HomeUser != cfg.HomeUser || got.DefaultServer != cfg.DefaultServer || + !reflect.DeepEqual(got.Servers, cfg.Servers) { + t.Fatal("saved configuration lost credentials or nested library settings") + } + output, err := json.Marshal(cfg.Servers) + if err != nil { + t.Fatal(err) + } + encodedToken, err := json.Marshal(cfg.Servers["shared"].AccessToken) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(output), string(encodedToken)) || strings.Contains(string(output), "source-fingerprint") { + t.Fatal("runtime JSON output no longer redacts server credentials") + } + }) + } +} diff --git a/internal/config/server_target.go b/internal/config/server_target.go index 1aa617a..db50ba5 100644 --- a/internal/config/server_target.go +++ b/internal/config/server_target.go @@ -9,16 +9,20 @@ import ( // ServerTarget is a snapshot of a connection. Views and playback retain it so // visiting a friend's library never changes the configured default server. type ServerTarget struct { - ID string - Name string - URL string - Token string `json:"-"` - Shared bool + ID string + Name string + URL string + Token string `json:"-"` + AccountToken string `json:"-"` + Shared bool } func (c *Config) ServerTarget() ServerTarget { id, server, _ := c.GetActiveServer() - return ServerTarget{ID: id, Name: server.Name, URL: server.URL, Token: c.ServerToken(), Shared: server.Shared} + return ServerTarget{ + ID: id, Name: server.Name, URL: server.URL, Token: c.ServerToken(), + AccountToken: c.AccountToken(), Shared: server.Shared, + } } // MediaServer supplies the current server for callers without an explicit target. @@ -30,7 +34,7 @@ func MediaServer(targets ...ServerTarget) ServerTarget { } func (s ServerTarget) CacheNamespace() string { - return fmt.Sprintf("%s/%x", s.ID, sha256.Sum256([]byte(s.Token))) + return fmt.Sprintf("%s/%x", s.ID, sha256.Sum256([]byte(s.URL+"\x00"+s.Token))) } type serverContextKey struct{} diff --git a/internal/mpvsetup/check.go b/internal/mpvsetup/check.go index 2176023..2e725fb 100644 --- a/internal/mpvsetup/check.go +++ b/internal/mpvsetup/check.go @@ -77,7 +77,10 @@ func installed(ctx context.Context) (Status, error) { func inspect(ctx context.Context, path string) (Status, error) { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, path, "--no-config", "--version").Output() + cmd := exec.CommandContext(ctx, path, "--no-config", "--version") + // Wrappers can leave descendants holding stdout after the probe exits. + cmd.WaitDelay = time.Second + out, err := cmd.Output() if err != nil { return Status{}, fmt.Errorf("check mpv at %s: %w", path, err) } diff --git a/internal/mpvsetup/check_integration_test.go b/internal/mpvsetup/check_integration_test.go new file mode 100644 index 0000000..72bea52 --- /dev/null +++ b/internal/mpvsetup/check_integration_test.go @@ -0,0 +1,50 @@ +//go:build integration && !windows + +package mpvsetup + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestInspectBoundsInheritedOutputPipe(t *testing.T) { + dir := t.TempDir() + path, pidPath := filepath.Join(dir, "mpv"), filepath.Join(dir, "child.pid") + t.Setenv("MPV_TEST_CHILD_PID", pidPath) + // The wrapper exits immediately, but its descendant retains stdout. + script := "#!/bin/sh\n/bin/sleep 30 &\nprintf '%s' \"$!\" > \"$MPV_TEST_CHILD_PID\"\nprintf 'mpv 0.41.0\\n'\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + finished := make(chan struct{}) + t.Cleanup(func() { + if data, err := os.ReadFile(pidPath); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + if child, err := os.FindProcess(pid); err == nil { + _ = child.Kill() + } + } + } + <-finished + }) + go func() { + defer close(finished) + _, err := inspect(t.Context(), path) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, exec.ErrWaitDelay) { + t.Fatalf("probe did not report its inherited pipe timeout: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("mpv version probe waited for a descendant's stdout") + } +} diff --git a/internal/plex/artwork_auth_test.go b/internal/plex/artwork_auth_test.go index bbcaf55..c758024 100644 --- a/internal/plex/artwork_auth_test.go +++ b/internal/plex/artwork_auth_test.go @@ -12,7 +12,7 @@ import ( func TestArtworkUsesHomeUserToken(t *testing.T) { t.Setenv("PLEXCTL_TOKEN", "") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("X-Plex-Token") != "home+user&token" { + if r.Header.Get("X-Plex-Token") != "home+user&token" || r.URL.Query().Has("X-Plex-Token") { http.Error(w, "wrong credentials", http.StatusUnauthorized) return } diff --git a/internal/plex/client.go b/internal/plex/client.go index fafcc14..81ba2ca 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "net/http/httputil" "runtime" "time" @@ -22,47 +21,37 @@ type loggingTransport struct { } func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.cfg.Logger.Debug("SDK Request", "method", req.Method, "url", req.URL.String()) - - if t.cfg.Enabled(config.LevelTrace) { - dump, err := httputil.DumpRequestOut(req, t.cfg.Verbosity >= 3) - if err == nil { - t.cfg.Logger.Log(context.Background(), config.LevelTrace, "SDK Request Dump", "dump", string(dump)) - } - } + // Headers, bodies, and query strings can contain account tokens, server + // resource tokens, or a Home user's PIN. Log only request metadata. + t.cfg.Logger.Debug("SDK Request", "method", req.Method, "url", safeURL(req.URL)) start := time.Now() res, err := t.base.RoundTrip(req) duration := time.Since(start) if err != nil { - t.cfg.Logger.Error("SDK Request Failed", "error", err, "duration", duration) + t.cfg.Logger.Error("SDK Request Failed", "error_type", fmt.Sprintf("%T", err), "duration", duration) return nil, err } - t.cfg.Logger.Debug("SDK Response", "status", res.Status, "duration", duration) - - if t.cfg.Enabled(config.LevelTrace) { - dump, err := httputil.DumpResponse(res, t.verbosityBody()) - if err == nil { - t.cfg.Logger.Log(context.Background(), config.LevelTrace, "SDK Response Dump", "dump", string(dump)) - } - } + t.cfg.Logger.Debug("SDK Response", "status", res.StatusCode, "duration", duration) return res, nil } -func (t *loggingTransport) verbosityBody() bool { - return t.cfg.Verbosity >= 3 -} - func NewClient() (*Client, error) { return NewClientWithToken(config.Get().ServerToken()) } // NewAccountClient uses the active Plex account or Home profile for plex.tv. func NewAccountClient() (*Client, error) { - return NewClientWithToken(config.Get().AccountToken()) + return NewAccountClientWithToken(config.Get().AccountToken()) +} + +// NewAccountClientWithToken keeps a captured account credential independent of +// the configured media server, including during a Home profile switch. +func NewAccountClientWithToken(token string) (*Client, error) { + return newClient(token, "") } // NewHomeHomeUserClient specifically uses the general user token. @@ -96,7 +85,8 @@ func newClient(token, serverURL string) (*Client, error) { base: http.DefaultTransport, cfg: cfg, }, - Timeout: 60 * time.Second, + Timeout: 60 * time.Second, + CheckRedirect: sameOriginRedirect, } opts := []plexgo.SDKOption{ @@ -120,5 +110,6 @@ func newClient(token, serverURL string) (*Client, error) { // HasServer returns true if a default server is configured func (c *Client) HasServer() bool { - return config.Get().DefaultServer != "" + _, server, ok := config.Get().GetActiveServer() + return ok && server.URL != "" } diff --git a/internal/plex/client_test.go b/internal/plex/client_test.go new file mode 100644 index 0000000..3a78e49 --- /dev/null +++ b/internal/plex/client_test.go @@ -0,0 +1,85 @@ +package plex + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestLoggingTransportOmitsCredentialsAndPreservesBodies(t *testing.T) { + t.Parallel() + var output bytes.Buffer + level := new(slog.LevelVar) + level.Set(config.LevelTrace) + cfg := &config.Config{ + Verbosity: 3, LogLevel: level, + Logger: slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{Level: level})), + } + const requestBody = `{"pin":"private-pin"}` + const responseBody = `{"authToken":"response-secret"}` + req, err := http.NewRequest(http.MethodPost, + "https://user:private-password@plex.invalid/switch?X-Plex-Token=query-secret", strings.NewReader(requestBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Plex-Token", "header-secret") + transport := &loggingTransport{cfg: cfg, base: resourceTransport(func(r *http.Request) (*http.Response, error) { + data, err := io.ReadAll(r.Body) + if err != nil || string(data) != requestBody { + t.Fatalf("logging consumed or changed the request body: %v", err) + } + return &http.Response{ + StatusCode: 200, Header: http.Header{"X-Plex-Token": {"response-header-secret"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + })} + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil || string(data) != responseBody { + t.Fatalf("logging consumed or changed the response body: %v", err) + } + for _, secret := range []string{"private-pin", "response-secret", "private-password", "query-secret", "header-secret", "response-header-secret"} { + if strings.Contains(output.String(), secret) { + t.Error("verbose SDK logging exposed a credential") + } + } + if !strings.Contains(output.String(), "SDK Request") || !strings.Contains(output.String(), "plex.invalid/switch") { + t.Fatal("request diagnostics were lost") + } +} + +func TestHasServerRequiresConfiguredEndpoint(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.Servers = map[string]config.Server{ + "configured": {URL: "https://plex.invalid"}, + "empty": {}, + } + client := &Client{} + for _, test := range []struct { + name, id string + want bool + }{ + {"no default", "", false}, + {"unknown default", "deleted-server", false}, + {"missing endpoint", "empty", false}, + {"configured", "configured", true}, + } { + t.Run(test.name, func(t *testing.T) { + cfg.DefaultServer = test.id + if got := client.HasServer(); got != test.want { + t.Fatalf("HasServer() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/plex/http.go b/internal/plex/http.go new file mode 100644 index 0000000..a38c9a4 --- /dev/null +++ b/internal/plex/http.go @@ -0,0 +1,75 @@ +package plex + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ygelfand/plexctl/internal/config" +) + +// MediaURL builds a stream URL without allowing server metadata to change the +// destination or supply its own credentials. +func MediaURL(server config.ServerTarget, path string) (string, error) { + u, err := mediaURL(server, path) + if err != nil { + return "", err + } + query := u.Query() + query.Set("X-Plex-Token", server.Token) + u.RawQuery = query.Encode() + return u.String(), nil +} + +func mediaURL(server config.ServerTarget, path string) (*url.URL, error) { + base, err := url.Parse(server.URL) + if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || + (base.Scheme != "https" && base.Scheme != "http") { + return nil, fmt.Errorf("invalid Plex server URL") + } + ref, err := url.Parse(path) + if err != nil || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || + ref.IsAbs() || ref.Host != "" || ref.User != nil || ref.Fragment != "" { + return nil, fmt.Errorf("invalid Plex media path") + } + // Assign path fields rather than concatenating complete URLs: a path such + // as @another-host/ must never become URL userinfo and change the host. + base.RawPath = strings.TrimRight(base.EscapedPath(), "/") + ref.EscapedPath() + base.Path = strings.TrimRight(base.Path, "/") + ref.Path + query, err := url.ParseQuery(ref.RawQuery) + if err != nil { + return nil, fmt.Errorf("invalid Plex media query") + } + for key := range query { + if strings.EqualFold(key, "X-Plex-Token") { + delete(query, key) + } + } + base.RawQuery = query.Encode() + return base, nil +} + +func safeURL(u *url.URL) string { + return u.Scheme + "://" + u.Host + u.EscapedPath() +} + +func sameOriginRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if len(via) == 0 { + return nil + } + origin := via[0].URL + if req.URL.User != nil || !strings.EqualFold(req.URL.Scheme, origin.Scheme) || + !strings.EqualFold(req.URL.Host, origin.Host) { + return http.ErrUseLastResponse + } + return nil +} + +func mediaHTTPClient() *http.Client { + return &http.Client{Timeout: 60 * time.Second, CheckRedirect: sameOriginRedirect} +} diff --git a/internal/plex/http_test.go b/internal/plex/http_test.go new file mode 100644 index 0000000..6dc1478 --- /dev/null +++ b/internal/plex/http_test.go @@ -0,0 +1,134 @@ +package plex + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestMediaURLRejectsOriginChanges(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, path string + }{ + {"userinfo injection", "@attacker.invalid/stolen"}, + {"absolute URL", "https://attacker.invalid/stolen"}, + {"network path", "//attacker.invalid/stolen"}, + {"relative path", "library/parts/1/file"}, + {"fragment", "/library/parts/1/file#fragment"}, + {"invalid query", "/library/parts/1/file?broken=%xy"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + server := config.ServerTarget{URL: "https://plex.invalid", Token: "private-token"} + if _, err := MediaURL(server, test.path); err == nil { + t.Fatal("unsafe media path was accepted") + } + }) + } +} + +func TestMediaURLPreservesPathAndUsesOnlyTheSelectedToken(t *testing.T) { + t.Parallel() + server := config.ServerTarget{URL: "https://plex.invalid/prefix/", Token: "private+&token"} + got, err := MediaURL(server, "/library/parts/one%2Ftwo/file?width=720&X-Plex-Token=spoof&x-plex-token=also-spoof") + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(got) + if err != nil { + t.Fatal(err) + } + if u.Host != "plex.invalid" || u.EscapedPath() != "/prefix/library/parts/one%2Ftwo/file" || + u.Query().Get("width") != "720" || u.Query().Get("X-Plex-Token") != server.Token || + u.Query().Has("x-plex-token") || len(u.Query()["X-Plex-Token"]) != 1 { + t.Fatal("media URL changed its origin, path, query or credential scope") + } +} + +func TestMediaRequestsRefuseCrossOriginRedirects(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + var leaked atomic.Bool + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + leaked.Store(true) + _, _ = io.WriteString(w, `{"MediaContainer":{"Metadata":[]}}`) + })) + defer destination.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "redirect-token" { + t.Error("original request did not use the selected server token") + } + if r.URL.Query().Has("X-Plex-Token") { + t.Error("nonstream request put its credential in the URL") + } + http.Redirect(w, r, destination.URL+"/stolen", http.StatusFound) + })) + defer origin.Close() + target := config.ServerTarget{ID: "redirect-test", URL: origin.URL, Token: "redirect-token"} + ctx := target.Context(context.Background()) + if _, err := GetMetadata(ctx, "1", true); err == nil { + t.Error("metadata accepted a cross-origin redirect") + } + if _, err := GetChildren(ctx, "1"); err == nil { + t.Error("listing accepted a cross-origin redirect") + } + if _, err := GetImage(ctx, "/poster"); err == nil { + t.Error("artwork accepted a cross-origin redirect") + } + if leaked.Load() { + t.Fatal("authenticated request reached the redirect target") + } +} + +func TestGetImageAllowsSameOriginRedirects(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/poster" { + http.Redirect(w, r, "/image", http.StatusFound) + return + } + if r.Header.Get("X-Plex-Token") != "art-token" { + t.Error("same-origin redirect lost authentication") + } + _, _ = io.WriteString(w, "artwork") + })) + defer server.Close() + target := config.ServerTarget{ID: "same-origin", URL: server.URL, Token: "art-token"} + got, err := GetImage(target.Context(context.Background()), "/poster") + if err != nil || string(got) != "artwork" { + t.Fatalf("same-origin image redirect failed: %v", err) + } +} + +func TestGetImageRejectsOversizedResponses(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + chunk := strings.Repeat("x", 8192) + for range 2049 { + if _, err := io.WriteString(w, chunk); err != nil { + return + } + } + })) + defer server.Close() + target := config.ServerTarget{ID: "oversized-art", URL: server.URL, Token: "art-token"} + if _, err := GetImage(target.Context(context.Background()), "/poster"); err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("expected oversized response rejection, got %v", err) + } +} diff --git a/internal/plex/loader.go b/internal/plex/loader.go index a655e56..007954f 100644 --- a/internal/plex/loader.go +++ b/internal/plex/loader.go @@ -36,41 +36,49 @@ const ( // LoadData performs the initial data fetch and caching. // It sends progress updates through the provided channel. func LoadData(ctx context.Context, updates chan<- any) { - sendUpdate := func(p float64, msg string) { - if updates != nil { - updates <- ProgressUpdate{Percent: p, Message: msg} + send := func(msg any) { + if updates == nil { + return + } + select { + case updates <- msg: + case <-ctx.Done(): } } + sendUpdate := func(p float64, msg string) { + send(ProgressUpdate{Percent: p, Message: msg}) + } cfg := config.Get() - serverID, _, ok := cfg.GetActiveServer() - if !ok { + server := config.ServerFromContext(ctx) + ctx = server.Context(ctx) + if server.ID == "" || server.URL == "" { slog.Error("Loader: No active server configured") - updates <- fmt.Errorf("no active server configured") + send(fmt.Errorf("no active server configured")) return } cm, err := cache.Get(cfg.CacheDir) if err != nil { slog.Error("Loader: Failed to get cache manager", "error", err) - updates <- err + send(err) return } sendUpdate(0.1, "Connecting to Plex...") slog.Debug("Loader: Initializing Plex client") - client, err := NewClient() + client, err := NewClientForContext(ctx) if err != nil { slog.Error("Loader: Client initialization failed", "error", err) - updates <- err + send(err) return } // 1. Get libraries list (Sections) sendUpdate(0.2, "Fetching libraries list...") - slog.Debug("Loader: Fetching sections from server", "server_id", serverID) + slog.Debug("Loader: Fetching sections from server", "server_id", server.ID) var sectionsBody operations.GetSectionsResponseBody - err = cache.AutoCache(cm, cfg.MediaCacheNamespace(), nil, LibraryCacheTTL, §ionsBody, func() (*operations.GetSectionsResponseBody, error) { + err = cache.AutoCache(cm, server.CacheNamespace(), nil, LibraryCacheTTL, §ionsBody, func() (*operations.GetSectionsResponseBody, error) { slog.Log(context.Background(), config.LevelTrace, "Loader: Sections Cache MISS, calling SDK") res, err := client.SDK.Library.GetSections(ctx) if err != nil { @@ -84,13 +92,13 @@ func LoadData(ctx context.Context, updates chan<- any) { if err != nil { slog.Error("Loader: Failed to get sections", "error", err) - updates <- err + send(err) return } if sectionsBody.MediaContainer == nil { slog.Error("Loader: No libraries found on server") - updates <- fmt.Errorf("no libraries found on server") + send(fmt.Errorf("no libraries found on server")) return } @@ -100,6 +108,9 @@ func LoadData(ctx context.Context, updates chan<- any) { libraries := make([]LibraryInfo, 0, total) for i, dir := range dirs { + if ctx.Err() != nil { + return + } key := ui.PtrToString(dir.Key) title := ui.PtrToString(dir.Title) @@ -110,7 +121,7 @@ func LoadData(ctx context.Context, updates chan<- any) { var libInfo LibraryInfo req := operations.ListContentRequest{SectionID: key} - err = cache.AutoCache(cm, cfg.MediaCacheNamespace(), req, LibraryCacheTTL, &libInfo, func() (*LibraryInfo, error) { + err = cache.AutoCache(cm, server.CacheNamespace(), req, LibraryCacheTTL, &libInfo, func() (*LibraryInfo, error) { slog.Log(context.Background(), config.LevelTrace, "Loader: Content Cache MISS", "library", title) count := 0 contentRes, err := client.SDK.Content.ListContent(ctx, req) @@ -145,8 +156,8 @@ func LoadData(ctx context.Context, updates chan<- any) { slog.Info("Loader: Initialization complete", "libraries", len(libraries)) sendUpdate(1.0, "Initialization complete!") - updates <- LoaderResult{ - ServerID: serverID, + send(LoaderResult{ + ServerID: server.ID, Libraries: libraries, - } + }) } diff --git a/internal/plex/loader_test.go b/internal/plex/loader_test.go new file mode 100644 index 0000000..ab6708b --- /dev/null +++ b/internal/plex/loader_test.go @@ -0,0 +1,75 @@ +package plex + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestLoadDataUsesCapturedServer(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir = t.TempDir() + cfg.Token = "different-profile" + cfg.HomeUser = config.HomeUser{} + cfg.DefaultServer = "different-server" + cfg.Servers = map[string]config.Server{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Plex-Token") != "captured-token" { + t.Error("loader used credentials from a different profile") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"Directory":[]}}`)) + })) + defer server.Close() + target := config.ServerTarget{ID: "captured-server", URL: server.URL, Token: "captured-token"} + updates := make(chan any, 10) + LoadData(target.Context(t.Context()), updates) + close(updates) + found := false + for update := range updates { + switch result := update.(type) { + case error: + t.Fatal(result) + case LoaderResult: + found = true + if result.ServerID != target.ID { + t.Fatalf("result belongs to wrong server: %q", result.ServerID) + } + } + } + if !found { + t.Fatal("loader never completed") + } +} + +func TestLoadDataCancellationUnblocksUpdates(t *testing.T) { + for _, tt := range []struct { + name string + updates chan any + }{ + {"no updates channel", nil}, + {"no receiver", make(chan any)}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel((config.ServerTarget{}).Context(t.Context())) + cancel() + done := make(chan struct{}) + go func() { + defer close(done) + LoadData(ctx, tt.updates) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("canceled loader blocked trying to report an update") + } + }) + } +} diff --git a/internal/plex/media.go b/internal/plex/media.go index eebeb55..d05c641 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -7,7 +7,6 @@ import ( "log/slog" "net/http" neturl "net/url" - "strings" "time" "github.com/LukeHagar/plexgo/models/components" @@ -96,7 +95,7 @@ func GetMetadata(ctx context.Context, ratingKey string, force bool) (*components return nil, err } - if len(body.MediaContainer.Metadata) == 0 { + if body.MediaContainer == nil || len(body.MediaContainer.Metadata) == 0 { return nil, fmt.Errorf("metadata empty") } @@ -131,17 +130,16 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { return data, nil } - url := fmt.Sprintf("%s%s", server.URL, path) - separator := "?" - if strings.Contains(url, "?") { - separator = "&" + u, err := mediaURL(server, path) + if err != nil { + return nil, err } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, neturl.QueryEscape(server.Token)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } - resp, err := http.DefaultClient.Do(req) + req.Header.Set("X-Plex-Token", server.Token) + resp, err := mediaHTTPClient().Do(req) if err != nil { return nil, err } @@ -155,10 +153,14 @@ func GetImage(ctx context.Context, path string) ([]byte, error) { return nil, fmt.Errorf("failed to fetch image: %s", resp.Status) } - data, err = io.ReadAll(resp.Body) + const maxImageBytes = 16 << 20 + data, err = io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) if err != nil { return nil, err } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("plex image exceeds the 16 MiB limit") + } _ = cm.Set(cacheKey, data, MediaCacheTTL) diff --git a/internal/plex/media_test.go b/internal/plex/media_test.go new file mode 100644 index 0000000..f7379f1 --- /dev/null +++ b/internal/plex/media_test.go @@ -0,0 +1,34 @@ +package plex + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestGetMetadataRejectsMissingContainer(t *testing.T) { + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir, cfg.NoCache = t.TempDir(), true + for _, test := range []struct{ name, body string }{ + {"omitted", `{}`}, + {"null", `{"MediaContainer":null}`}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, test.body) + })) + defer server.Close() + target := config.ServerTarget{ID: "missing-container", URL: server.URL, Token: "token"} + if _, err := GetMetadata(target.Context(context.Background()), "1", true); err == nil { + t.Fatal("missing metadata container was accepted") + } + }) + } +} diff --git a/internal/plex/music.go b/internal/plex/music.go index 190ca5a..34e32e3 100644 --- a/internal/plex/music.go +++ b/internal/plex/music.go @@ -151,7 +151,11 @@ func metadataListing(ctx context.Context, path string, query url.Values) ([]comp if err := ctx.Err(); err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+path+"?"+query.Encode(), nil) + u, err := mediaURL(server, path+"?"+query.Encode()) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } @@ -159,7 +163,7 @@ func metadataListing(ctx context.Context, path string, query url.Values) ([]comp req.Header.Set("Accept", "application/json") req.Header.Set("X-Plex-Container-Start", strconv.Itoa(start)) req.Header.Set("X-Plex-Container-Size", strconv.Itoa(pageSize)) - resp, err := http.DefaultClient.Do(req) + resp, err := mediaHTTPClient().Do(req) if err != nil { return nil, err } diff --git a/internal/plex/pagination.go b/internal/plex/pagination.go index ed874dd..0566816 100644 --- a/internal/plex/pagination.go +++ b/internal/plex/pagination.go @@ -2,6 +2,9 @@ package plex import ( "context" + "encoding/json" + "fmt" + "math" "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" @@ -20,28 +23,48 @@ func WalkContent(ctx context.Context, all bool, page, count int, walker ContentW if all { size = 100 // doesn't seem to make much of a difference how many per page once it gets here } else { + if page < 1 || count < 1 { + return nil, fmt.Errorf("page and count must be positive") + } + if page-1 > math.MaxInt/count { + return nil, fmt.Errorf("pagination offset is out of range") + } start = (page - 1) * count } + previousPage := "" for { + if err := ctx.Err(); err != nil { + return nil, err + } metadata, totalSize, err := walker(ctx, start, size) if err != nil { return nil, err } - allMetadata = append(allMetadata, metadata...) - if !all { + return metadata, nil + } + if len(metadata) == 0 { + if totalSize > int64(start) { + return nil, fmt.Errorf("server returned an incomplete content listing") + } break } + encoded, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + if string(encoded) == previousPage { + return nil, fmt.Errorf("server did not advance content pagination") + } + previousPage = string(encoded) + allMetadata = append(allMetadata, metadata...) start += len(metadata) if totalSize > 0 && int64(start) >= totalSize { break } - if len(metadata) == 0 { - break - } } return allMetadata, nil diff --git a/internal/plex/pagination_test.go b/internal/plex/pagination_test.go new file mode 100644 index 0000000..ad7b6f3 --- /dev/null +++ b/internal/plex/pagination_test.go @@ -0,0 +1,93 @@ +package plex + +import ( + "context" + "errors" + "math" + "testing" + + "github.com/LukeHagar/plexgo/models/components" +) + +func TestWalkContentValidatesOffsets(t *testing.T) { + for _, tt := range []struct { + name string + page, count int + }{ + {"zero page", 0, 10}, + {"negative page", -1, 10}, + {"zero count", 1, 0}, + {"negative count", 1, -10}, + {"overflow", math.MaxInt, 2}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := WalkContent(t.Context(), false, tt.page, tt.count, func(context.Context, int, int) ([]components.Metadata, int64, error) { + t.Fatal("invalid pagination reached the server") + return nil, 0, nil + }) + if err == nil { + t.Fatal("invalid pagination succeeded") + } + }) + } +} + +func TestWalkContentRejectsBrokenPagination(t *testing.T) { + for _, tt := range []struct { + name string + repeated bool + }{ + {"repeated page", true}, + {"empty incomplete page", false}, + } { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + _, err := WalkContent(t.Context(), true, 1, 10, func(_ context.Context, start, size int) ([]components.Metadata, int64, error) { + calls++ + if calls > 2 { + t.Fatal("walker continued after server failed to advance") + } + if tt.repeated || start == 0 { + return []components.Metadata{{RatingKey: ptr("1"), Title: "first"}}, 3, nil + } + return nil, 3, nil + }) + if err == nil { + t.Fatal("broken pagination returned an apparently complete listing") + } + }) + } +} + +func TestWalkContentRespectsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + calls := 0 + _, err := WalkContent(ctx, true, 1, 10, func(context.Context, int, int) ([]components.Metadata, int64, error) { + calls++ + cancel() + return []components.Metadata{{RatingKey: ptr("1")}}, 2, nil + }) + if !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("canceled pagination: error=%v, calls=%d", err, calls) + } +} + +func TestWalkContentCollectsPages(t *testing.T) { + items, err := WalkContent(t.Context(), true, 0, 0, func(_ context.Context, start, size int) ([]components.Metadata, int64, error) { + if size != 100 { + t.Errorf("unexpected page size %d", size) + } + switch start { + case 0: + return []components.Metadata{{Title: "first"}, {Title: "second"}}, 3, nil + case 2: + return []components.Metadata{{Title: "third"}}, 3, nil + default: + t.Fatalf("unexpected offset %d", start) + return nil, 0, nil + } + }) + if err != nil || len(items) != 3 || items[2].Title != "third" { + t.Fatalf("pagination: %#v, %v", items, err) + } +} diff --git a/internal/plex/servers.go b/internal/plex/servers.go index 05e431c..f50151a 100644 --- a/internal/plex/servers.go +++ b/internal/plex/servers.go @@ -18,7 +18,7 @@ import ( // DiscoverServers includes servers shared with the active Plex account/profile. func DiscoverServers(ctx context.Context) ([]components.PlexDevice, error) { - client, err := NewAccountClient() + client, err := NewAccountClientWithToken(config.ServerFromContext(ctx).AccountToken) if err != nil { return nil, err } @@ -70,7 +70,7 @@ func ConnectServer(ctx context.Context, device components.PlexDevice) (config.Se client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} for _, conn := range connections { u, err := url.Parse(conn.URI) - if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") || (device.HTTPSRequired && u.Scheme != "https") { continue } @@ -108,7 +108,11 @@ func probeServer(ctx context.Context, client *http.Client, target config.ServerT return err } req.Header.Set("Accept", "application/json") - req.Header.Set("X-Plex-Token", target.Token) + // The identity endpoint is public. First verify that an advertised + // private address belongs to this server, then send its access token. + if path != "/identity" { + req.Header.Set("X-Plex-Token", target.Token) + } req.Header.Set("X-Plex-Client-Identifier", config.ClientIdentifier()) resp, err := client.Do(req) if err != nil { diff --git a/internal/plex/servers_test.go b/internal/plex/servers_test.go index 4a76342..6f8bc80 100644 --- a/internal/plex/servers_test.go +++ b/internal/plex/servers_test.go @@ -16,7 +16,7 @@ type resourceTransport func(*http.Request) (*http.Response, error) func (f resourceTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } -func TestDiscoveryUsesAccountTokenAndIncludesFriends(t *testing.T) { +func TestDiscoveryUsesCapturedAccountTokenAndIncludesFriends(t *testing.T) { t.Setenv("PLEXCTL_TOKEN", "") cfg := config.Get() old := *cfg @@ -24,6 +24,8 @@ func TestDiscoveryUsesAccountTokenAndIncludesFriends(t *testing.T) { t.Cleanup(func() { *cfg = old; http.DefaultTransport = oldTransport }) cfg.Token = "account" cfg.HomeUser = config.HomeUser{AuthToken: "profile-account", AccessToken: "server-only"} + ctx := cfg.ServerTarget().Context(t.Context()) + cfg.HomeUser = config.HomeUser{AuthToken: "new-profile", AccessToken: "new-server-token"} fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Plex-Token") != "profile-account" { t.Error("discovery used a server token") @@ -47,7 +49,7 @@ func TestDiscoveryUsesAccountTokenAndIncludesFriends(t *testing.T) { clone.URL.Scheme, clone.URL.Host = "http", strings.TrimPrefix(fixture.URL, "http://") return oldTransport.RoundTrip(clone) }) - servers, err := DiscoverServers(context.Background()) + servers, err := DiscoverServers(ctx) if err != nil || len(servers) != 2 || !servers[0].Owned || servers[1].AccessToken != "shared" { t.Fatalf("discover owned and shared servers: count=%d, error=%v", len(servers), err) } @@ -58,7 +60,11 @@ func TestConnectSharedServerFallsBackToRelayWithoutAccountToken(t *testing.T) { makeServer := func(name, identity string, status int) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts = append(attempts, name) - if r.Header.Get("X-Plex-Token") != "resource-token" { + wantToken := "resource-token" + if r.URL.Path == "/identity" { + wantToken = "" + } + if r.Header.Get("X-Plex-Token") != wantToken { t.Error("wrong shared-server credentials") } w.Header().Set("Content-Type", "application/json") diff --git a/internal/plex/store.go b/internal/plex/store.go index f198221..0ec64fd 100644 --- a/internal/plex/store.go +++ b/internal/plex/store.go @@ -12,8 +12,10 @@ import ( ) type Store struct { - client *Client - cache *cache.Manager + client *Client + account *Client + cache *cache.Manager + server config.ServerTarget // Cache for IDs to Titles userCache map[int64]string @@ -22,36 +24,56 @@ type Store struct { mu sync.RWMutex } +type storeKey struct { + server config.ServerTarget + cacheDir string +} + var ( - storeInstance *Store - storeOnce sync.Once + storesMu sync.Mutex + stores = make(map[storeKey]*Store) ) func GetStore() (*Store, error) { - var err error - storeOnce.Do(func() { - client, cErr := NewClient() - if cErr != nil { - err = cErr - return - } - - cfg := config.Get() - cm, cErr := cache.Get(cfg.CacheDir) - if cErr != nil { - err = cErr - return - } + return GetStoreForContext(context.Background()) +} - storeInstance = &Store{ - client: client, - cache: cm, - userCache: make(map[int64]string), - deviceCache: make(map[string]string), - libraryCache: make(map[string]string), +// GetStoreForContext retains the credentials that own the request and its caches. +// Switching profiles must never reuse a previous profile's authenticated client. +func GetStoreForContext(ctx context.Context) (*Store, error) { + server := config.ServerFromContext(ctx) + key := storeKey{server: server, cacheDir: config.Get().CacheDir} + storesMu.Lock() + defer storesMu.Unlock() + if store := stores[key]; store != nil { + return store, nil + } + client, err := newClient(server.Token, server.URL) + if err != nil { + return nil, err + } + var account *Client + if server.AccountToken != "" { + account, err = newClient(server.AccountToken, "") + if err != nil { + return nil, err } - }) - return storeInstance, err + } + cm, err := cache.Get(key.cacheDir) + if err != nil { + return nil, err + } + store := &Store{ + client: client, + account: account, + cache: cm, + server: server, + userCache: make(map[int64]string), + deviceCache: make(map[string]string), + libraryCache: make(map[string]string), + } + stores[key] = store + return store, nil } func (s *Store) ResolveUser(ctx context.Context, id int64) (string, error) { @@ -63,7 +85,10 @@ func (s *Store) ResolveUser(ctx context.Context, id int64) (string, error) { s.mu.RUnlock() // Refresh cache - res, err := s.client.SDK.Users.GetUsers(ctx, operations.GetUsersRequest{}) + if s.account == nil { + return fmt.Sprintf("%d", id), fmt.Errorf("no account credential for user lookup") + } + res, err := s.account.SDK.Users.GetUsers(ctx, operations.GetUsersRequest{}) if err != nil { return fmt.Sprintf("%d", id), err } @@ -151,9 +176,6 @@ func (s *Store) ListPlaybackHistory(ctx context.Context, request operations.List } func (s *Store) GetMetadata(ctx context.Context, ratingKey string, force bool) (*components.Metadata, error) { - cfg := config.Get() - serverID, _, _ := cfg.GetActiveServer() - var body components.MediaContainerWithMetadata req := operations.GetMetadataItemRequest{ Ids: []string{ratingKey}, @@ -165,7 +187,7 @@ func (s *Store) GetMetadata(ctx context.Context, ratingKey string, force bool) ( ttl = 0 } - err := cache.AutoCache(s.cache, serverID, req, ttl, &body, func() (*components.MediaContainerWithMetadata, error) { + err := cache.AutoCache(s.cache, s.server.CacheNamespace(), req, ttl, &body, func() (*components.MediaContainerWithMetadata, error) { res, err := s.client.SDK.Content.GetMetadataItem(ctx, req) if err != nil { return nil, err @@ -179,7 +201,7 @@ func (s *Store) GetMetadata(ctx context.Context, ratingKey string, force bool) ( return nil, err } - if len(body.MediaContainer.Metadata) == 0 { + if body.MediaContainer == nil || len(body.MediaContainer.Metadata) == 0 { return nil, fmt.Errorf("metadata empty") } diff --git a/internal/plex/store_test.go b/internal/plex/store_test.go new file mode 100644 index 0000000..1c94bd6 --- /dev/null +++ b/internal/plex/store_test.go @@ -0,0 +1,116 @@ +package plex + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestStoreSeparatesProfilesAndRetainsRequestCredentials(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir = t.TempDir() + cfg.NoCache = false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + token := r.Header.Get("X-Plex-Token") + container := map[string]any{} + switch r.URL.Path { + case "/api/users": + container["User"] = []map[string]any{{"id": 1, "title": token}} + case "/library/sections/all": + container["Directory"] = []map[string]any{{"key": "1", "title": token, "type": "movie"}} + case "/library/metadata/1": + container["Metadata"] = []map[string]any{{"ratingKey": "1", "title": token, "type": "movie"}} + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": container}) + })) + defer server.Close() + // The SDK fixes account lookups to plex.tv; redirect every transport request + // to our local fixture so no test can contact an actual account endpoint. + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + transport := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = transport }) + http.DefaultTransport = resourceTransport(func(req *http.Request) (*http.Response, error) { + local := req.Clone(req.Context()) + local.URL.Scheme, local.URL.Host = endpoint.Scheme, endpoint.Host + return transport.RoundTrip(local) + }) + cfg.Token = "owner" + cfg.HomeUser = config.HomeUser{} + cfg.DefaultServer = "profile-store-test" + cfg.Servers = map[string]config.Server{cfg.DefaultServer: { + URL: server.URL, AccessToken: "owner-resource", TokenSource: cfg.TokenSource(), + }} + ownerContext := cfg.ServerTarget().Context(t.Context()) + owner, err := GetStore() + if err != nil { + t.Fatal(err) + } + checkStore := func(store *Store, wantAccount, wantServer string) { + t.Helper() + user, err := store.ResolveUser(t.Context(), 1) + if err != nil || user != wantAccount { + t.Fatalf("user = %q, %v; want %q", user, err, wantAccount) + } + library, err := store.ResolveLibrary(t.Context(), "1") + if err != nil || library != wantServer { + t.Fatalf("library = %q, %v; want %q", library, err, wantServer) + } + meta, err := store.GetMetadata(t.Context(), "1", false) + if err != nil { + t.Fatal(err) + } + if meta.Title != wantServer { + t.Fatalf("metadata from wrong user: %q, want %q", meta.Title, wantServer) + } + } + checkStore(owner, "owner", "owner-resource") + cfg.HomeUser = config.HomeUser{AuthToken: "restricted", AccessToken: "restricted-resource", ServerID: cfg.DefaultServer} + restricted, err := GetStore() + if err != nil { + t.Fatal(err) + } + checkStore(restricted, "restricted", "restricted-resource") + retained, err := GetStoreForContext(ownerContext) + if err != nil { + t.Fatal(err) + } + checkStore(retained, "owner", "owner-resource") +} + +func TestStoreInitializationCanRetryAndRejectsEmptyMetadata(t *testing.T) { + t.Setenv("PLEXCTL_TOKEN", "") + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + cfg.CacheDir = t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + target := config.ServerTarget{ID: "retry-store", URL: server.URL} + if _, err := GetStoreForContext(target.Context(t.Context())); err == nil { + t.Fatal("missing credentials unexpectedly succeeded") + } + target.Token = "valid" + store, err := GetStoreForContext(target.Context(t.Context())) + if err != nil || store == nil { + t.Fatalf("store could not recover after configuration was fixed: %v", err) + } + if _, err := store.GetMetadata(t.Context(), "1", true); err == nil { + t.Fatal("missing MediaContainer unexpectedly succeeded") + } +} diff --git a/internal/presenters/server.go b/internal/presenters/server.go index 89ff34c..a7515c8 100644 --- a/internal/presenters/server.go +++ b/internal/presenters/server.go @@ -141,7 +141,7 @@ func (p *DevicesPresenter) Rows() [][]string { } func (p *DevicesPresenter) Raw() any { - return p.Devices + return (&ServerListPresenter{Devices: p.Devices}).Raw() } func (p *DevicesPresenter) SortableColumns() []string { diff --git a/internal/presenters/server_test.go b/internal/presenters/server_test.go new file mode 100644 index 0000000..4505777 --- /dev/null +++ b/internal/presenters/server_test.go @@ -0,0 +1,33 @@ +package presenters + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/LukeHagar/plexgo/models/components" +) + +func TestDeviceAndServerOutputOmitTokens(t *testing.T) { + devices := []components.PlexDevice{{Name: "server", AccessToken: "secret-resource-token"}} + for _, tt := range []struct { + name string + raw any + }{ + {"servers", (&ServerListPresenter{Devices: devices}).Raw()}, + {"devices", (&DevicesPresenter{Devices: devices}).Raw()}, + } { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.raw) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret-resource-token") { + t.Fatal("machine-readable device output exposes a resource token") + } + if devices[0].AccessToken != "secret-resource-token" { + t.Fatal("rendering output changed the caller's credentials") + } + }) + } +} diff --git a/internal/search/index.go b/internal/search/index.go index 26782d4..7fcb0fc 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -38,6 +38,9 @@ type SearchIndex struct { Entries []IndexEntry `json:"entries"` mu sync.RWMutex reindexMu sync.Mutex + scopeOnce sync.Once + server config.ServerTarget + cacheDir string } // SearchableText includes music context, so an album or track can also be found @@ -73,9 +76,11 @@ var ( func GetIndex() *SearchIndex { indexMu.Lock() defer indexMu.Unlock() - namespace := config.Get().MediaCacheNamespace() - if indexInstance == nil || indexNamespace != namespace { + cfg := config.Get() + namespace := cfg.MediaCacheNamespace() + if indexInstance == nil || indexNamespace != namespace || indexInstance.cacheDir != cfg.CacheDir { indexInstance = &SearchIndex{} + indexInstance.bindScope() indexNamespace = namespace _ = indexInstance.Load() } @@ -83,13 +88,13 @@ func GetIndex() *SearchIndex { } func (idx *SearchIndex) Load() error { - cfg := config.Get() - cm, err := cache.Get(cfg.CacheDir) + idx.bindScope() + cm, err := cache.Get(idx.cacheDir) if err != nil { return err } - key := fmt.Sprintf("%s/search_index", cfg.MediaCacheNamespace()) + key := fmt.Sprintf("%s/search_index", idx.server.CacheNamespace()) var data SearchIndex if err := cm.Get(key, &data); err == nil { @@ -103,19 +108,28 @@ func (idx *SearchIndex) Load() error { } func (idx *SearchIndex) Save() error { - cfg := config.Get() - cm, err := cache.Get(cfg.CacheDir) + idx.bindScope() + cm, err := cache.Get(idx.cacheDir) if err != nil { return err } - key := fmt.Sprintf("%s/search_index", cfg.MediaCacheNamespace()) + key := fmt.Sprintf("%s/search_index", idx.server.CacheNamespace()) idx.mu.RLock() defer idx.mu.RUnlock() return cm.Set(key, idx, 0) } +// An index retains the identity it was created for, including when a Home +// profile switch happens while a background reindex is still running. +func (idx *SearchIndex) bindScope() { + idx.scopeOnce.Do(func() { + cfg := config.Get() + idx.server, idx.cacheDir = cfg.ServerTarget(), cfg.CacheDir + }) +} + type IndexProgress struct { Current int Total int @@ -123,9 +137,11 @@ type IndexProgress struct { } func (idx *SearchIndex) Reindex(ctx context.Context, progress chan<- IndexProgress) error { + idx.bindScope() idx.reindexMu.Lock() defer idx.reindexMu.Unlock() - client, err := plex.NewClient() + ctx = idx.server.Context(ctx) + client, err := plex.NewClientForContext(ctx) if err != nil { return err } @@ -196,7 +212,7 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s } if res.MediaContainerWithMetadata == nil || res.MediaContainerWithMetadata.MediaContainer == nil { - break + return nil, fmt.Errorf("missing library metadata") } mc := res.MediaContainerWithMetadata.MediaContainer @@ -224,8 +240,14 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s music = music || meta.Type == "artist" if meta.Type == "show" && meta.RatingKey != nil { - seasons, _ := plex.GetChildren(ctx, *meta.RatingKey) + seasons, err := plex.GetChildren(ctx, *meta.RatingKey) + if err != nil { + return nil, err + } for _, season := range seasons { + if season.RatingKey == nil || *season.RatingKey == "" { + return nil, fmt.Errorf("season in show %s has no rating key", *meta.RatingKey) + } entries = append(entries, IndexEntry{ RatingKey: ui.PtrToString(season.RatingKey), Title: fmt.Sprintf("%s - %s", meta.Title, season.Title), @@ -234,7 +256,10 @@ func (idx *SearchIndex) indexLibrary(ctx context.Context, client *plex.Client, s Library: libTitle, }) - episodes, _ := plex.GetChildren(ctx, *season.RatingKey) + episodes, err := plex.GetChildren(ctx, *season.RatingKey) + if err != nil { + return nil, err + } for _, ep := range episodes { epEntry := IndexEntry{ RatingKey: ui.PtrToString(ep.RatingKey), diff --git a/internal/search/index_test.go b/internal/search/index_test.go new file mode 100644 index 0000000..a6efbdd --- /dev/null +++ b/internal/search/index_test.go @@ -0,0 +1,108 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/cache" + "github.com/ygelfand/plexctl/internal/config" +) + +func configureIndexTest(t *testing.T, url string) *config.Config { + t.Helper() + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + t.Setenv("PLEXCTL_TOKEN", "") + cfg.DefaultServer, cfg.CacheDir, cfg.NoCache = "index-test", t.TempDir(), false + cfg.Servers = map[string]config.Server{"index-test": {URL: url}} + cfg.HomeUser = config.HomeUser{AuthToken: "original-user", AccessToken: "original-user", ServerID: "index-test"} + return cfg +} + +func TestIndexRetainsItsProfileForChildrenAndPersistence(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token := r.Header.Get("X-Plex-Token"); token != "original-user" { + t.Errorf("request %s used another profile's token: %q", r.URL.Path, token) + } + var container any + switch r.URL.Path { + case "/library/sections/all": + container = map[string]any{"Directory": []any{map[string]any{"key": "1", "title": "Shows", "type": "show"}}} + case "/library/sections/1/all": + container = map[string]any{"totalSize": 1, "Metadata": []any{map[string]any{"ratingKey": "show", "title": "Private show", "type": "show"}}} + case "/library/metadata/show/children": + container = map[string]any{"totalSize": 1, "Metadata": []any{map[string]any{"ratingKey": "season", "title": "Season", "type": "season"}}} + case "/library/metadata/season/children": + container = map[string]any{"totalSize": 1, "Metadata": []any{map[string]any{"ratingKey": "episode", "title": "Episode", "type": "episode"}}} + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"MediaContainer": container}) + })) + defer server.Close() + cfg := configureIndexTest(t, server.URL) + original := cfg.ServerTarget() + idx := GetIndex() + // The reindex command may be scheduled after the user has switched profiles. + cfg.HomeUser = config.HomeUser{AuthToken: "new-user", AccessToken: "new-user", ServerID: "index-test"} + if err := idx.Reindex(t.Context(), nil); err != nil { + t.Fatal(err) + } + cm, err := cache.Get(cfg.CacheDir) + if err != nil { + t.Fatal(err) + } + var saved SearchIndex + if err := cm.Get(original.CacheNamespace()+"/search_index", &saved); err != nil || len(saved.Entries) != 3 { + t.Fatalf("original profile index was not saved: entries=%d err=%v", len(saved.Entries), err) + } + if err := cm.Get(cfg.MediaCacheNamespace()+"/search_index", &saved); err == nil { + t.Fatal("private search results were written to the new profile's cache") + } +} + +func TestIndexFailurePreservesCompletedSnapshot(t *testing.T) { + for _, failure := range []string{"season without rating key", "children request fails", "missing library container"} { + t.Run(failure, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/library/sections/all": + _, _ = fmt.Fprint(w, `{"MediaContainer":{"Directory":[{"key":"1","title":"Shows","type":"show"}]}}`) + case "/library/sections/1/all": + if failure == "missing library container" { + _, _ = fmt.Fprint(w, `{}`) + return + } + _, _ = fmt.Fprint(w, `{"MediaContainer":{"totalSize":1,"Metadata":[{"ratingKey":"show","title":"Show","type":"show"}]}}`) + case "/library/metadata/show/children": + if failure == "children request fails" { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = fmt.Fprint(w, `{"MediaContainer":{"totalSize":1,"Metadata":[{"title":"Season","type":"season"}]}}`) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer server.Close() + configureIndexTest(t, server.URL) + indexedAt := time.Now().Add(-time.Hour) + idx := SearchIndex{Entries: []IndexEntry{{RatingKey: "saved"}}, LastIndexed: indexedAt} + if err := idx.Reindex(context.Background(), nil); err == nil { + t.Fatal("incomplete indexing succeeded") + } + entries, gotTime := idx.Snapshot() + if len(entries) != 1 || entries[0].RatingKey != "saved" || !gotTime.Equal(indexedAt) { + t.Fatal("failed indexing replaced the last completed snapshot") + } + }) + } +} diff --git a/internal/termgraphics/pane.go b/internal/termgraphics/pane.go index ab39cdc..def32e7 100644 --- a/internal/termgraphics/pane.go +++ b/internal/termgraphics/pane.go @@ -34,7 +34,9 @@ func NewPaneZoom() (*PaneZoom, error) { return newPaneZoom(os.Getenv("TMUX_PANE"), func(args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - return exec.CommandContext(ctx, "tmux", args...).Output() + cmd := exec.CommandContext(ctx, "tmux", args...) + cmd.WaitDelay = time.Second + return cmd.Output() }) } diff --git a/internal/termgraphics/terminal.go b/internal/termgraphics/terminal.go index f25b630..118e67f 100644 --- a/internal/termgraphics/terminal.go +++ b/internal/termgraphics/terminal.go @@ -82,7 +82,9 @@ func InTmux() bool { } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - tty, err := exec.CommandContext(ctx, "tmux", "display-message", "-p", "-t", pane, "#{pane_tty}").Output() + cmd := exec.CommandContext(ctx, "tmux", "display-message", "-p", "-t", pane, "#{pane_tty}") + cmd.WaitDelay = time.Second + tty, err := cmd.Output() if err == nil { tmuxTerminal.active = sameTerminal(strings.TrimSpace(string(tty)), os.Stdin, os.Stdout) } @@ -123,7 +125,9 @@ func EnablePassthrough() (func(), error) { run := func(args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - return exec.CommandContext(ctx, "tmux", args...).Output() + cmd := exec.CommandContext(ctx, "tmux", args...) + cmd.WaitDelay = time.Second + return cmd.Output() } old, err := run("show-options", "-p", "-v", "-t", pane, "allow-passthrough") if err != nil { diff --git a/internal/tui/artwork/decode.go b/internal/tui/artwork/decode.go new file mode 100644 index 0000000..81f72bc --- /dev/null +++ b/internal/tui/artwork/decode.go @@ -0,0 +1,27 @@ +package artwork + +import ( + "bytes" + "fmt" + "image" + + _ "image/jpeg" + _ "image/png" +) + +// Decode checks dimensions before a decoder allocates the image's pixel buffer. +// Poster responses come from remote servers and may be tiny compressed images +// whose decoded dimensions would otherwise exhaust memory. +func Decode(data []byte) (image.Image, error) { + info, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + const maxPixels = 16 << 20 + if info.Width <= 0 || info.Height <= 0 || info.Width > 8192 || info.Height > 8192 || + info.Width > maxPixels/info.Height { + return nil, fmt.Errorf("poster dimensions exceed the 16 megapixel limit") + } + img, _, err := image.Decode(bytes.NewReader(data)) + return img, err +} diff --git a/internal/tui/artwork/decode_test.go b/internal/tui/artwork/decode_test.go new file mode 100644 index 0000000..6b65924 --- /dev/null +++ b/internal/tui/artwork/decode_test.go @@ -0,0 +1,49 @@ +package artwork + +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "image" + "image/png" + "strings" + "testing" +) + +func TestDecodeRejectsOversizedImageBeforeDecodingPixels(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + width, height uint32 + }{ + {"huge width", 100000, 1}, + {"huge height", 1, 100000}, + {"too many pixels", 8192, 8192}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + // A complete PNG header is enough to trigger the dimension check; + // no pixel data or large allocations are needed for this regression. + data := append([]byte{}, []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR")...) + data = binary.BigEndian.AppendUint32(data, test.width) + data = binary.BigEndian.AppendUint32(data, test.height) + data = append(data, 8, 2, 0, 0, 0) + data = binary.BigEndian.AppendUint32(data, crc32.ChecksumIEEE(data[12:])) + if _, err := Decode(data); err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("expected dimension limit rejection, got %v", err) + } + }) + } +} + +func TestDecodeSmallPoster(t *testing.T) { + t.Parallel() + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil { + t.Fatal(err) + } + img, err := Decode(encoded.Bytes()) + if err != nil || img.Bounds() != image.Rect(0, 0, 2, 3) { + t.Fatalf("decode small poster: %v", err) + } +} diff --git a/internal/tui/player/actions.go b/internal/tui/player/actions.go index c2e9a43..b6b0a6f 100644 --- a/internal/tui/player/actions.go +++ b/internal/tui/player/actions.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "log/slog" - "net/url" "strings" + "time" "github.com/LukeHagar/plexgo/models/components" tea "github.com/charmbracelet/bubbletea" @@ -34,6 +34,8 @@ type PlayRequest struct { // BuildPlayRequest resolves containers before starting mpv, so an unsuccessful // library request cannot interrupt the currently playing item. func BuildPlayRequest(ctx context.Context, metadata *components.Metadata, noReport bool) (*PlayRequest, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() server := config.ServerFromContext(ctx) if server.URL == "" { return nil, fmt.Errorf("no active server") @@ -55,7 +57,11 @@ func BuildPlayRequest(ctx context.Context, metadata *components.Metadata, noRepo if part == nil { return nil, fmt.Errorf("no playable media found") } - item := PlayItem{URL: partURL(server, part.Key), Title: track.Title} + mediaURL, err := plex.MediaURL(server, part.Key) + if err != nil { + return nil, fmt.Errorf("invalid media path: %w", err) + } + item := PlayItem{URL: mediaURL, Title: track.Title} if track.RatingKey != nil { item.RatingKey = *track.RatingKey } @@ -80,22 +86,18 @@ func BuildPlayRequest(ctx context.Context, metadata *components.Metadata, noRepo if stream.LanguageCode != nil { language = *stream.LanguageCode } + subtitleURL, err := plex.MediaURL(server, stream.Key) + if err != nil { + return nil, fmt.Errorf("invalid subtitle path: %w", err) + } req.Subtitles = append(req.Subtitles, ExternalSubtitle{ - URL: partURL(server, stream.Key), Title: stream.DisplayTitle, Language: language, + URL: subtitleURL, Title: stream.DisplayTitle, Language: language, }) } } return req, nil } -func partURL(server config.ServerTarget, key string) string { - separator := "?" - if strings.Contains(key, "?") { - separator = "&" - } - return server.URL + key + separator + "X-Plex-Token=" + url.QueryEscape(server.Token) -} - func PlayMedia(parent context.Context, metadata *components.Metadata, noReport bool, output config.VideoOutput, startOffset int64, servers ...config.ServerTarget) tea.Cmd { server := config.MediaServer(servers...) pm := GetPlayerManager() @@ -122,7 +124,9 @@ func FetchAndPlay(ratingKey string, output config.VideoOutput, servers ...config pm := GetPlayerManager() ctx := pm.beginRequest(server.Context(context.Background())) return func() tea.Msg { - meta, err := plex.GetMetadata(ctx, ratingKey, true) + metadataCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + meta, err := plex.GetMetadata(metadataCtx, ratingKey, true) if ctx.Err() == context.Canceled { return nil } @@ -171,7 +175,9 @@ func FetchAndPlayTrailer(parent context.Context, parentMeta *components.Metadata slog.Debug("FetchAndPlayTrailer: Resolved trailer key", "parent", parentMeta.Title, "target", ratingKey) - meta, err := plex.GetMetadata(ctx, ratingKey, false) + metadataCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + meta, err := plex.GetMetadata(metadataCtx, ratingKey, false) if ctx.Err() == context.Canceled { return nil } diff --git a/internal/tui/player/actions_queue_test.go b/internal/tui/player/actions_queue_test.go index 574aef2..3bf3a72 100644 --- a/internal/tui/player/actions_queue_test.go +++ b/internal/tui/player/actions_queue_test.go @@ -150,7 +150,7 @@ func TestResolveAndPlayCancelsObsoleteQueue(t *testing.T) { for { select { case mediaURL := <-loaded: - if action == "stop" || mediaURL != partURL(target, "/new.flac") { + if action == "stop" || mediaURL != target.URL+"/new.flac?X-Plex-Token=test-token" { t.Errorf("obsolete queue reached mpv: %s", mediaURL) } default: diff --git a/internal/tui/player/actions_test.go b/internal/tui/player/actions_test.go index 888d9ab..accdf4f 100644 --- a/internal/tui/player/actions_test.go +++ b/internal/tui/player/actions_test.go @@ -5,13 +5,46 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" "github.com/ygelfand/plexctl/internal/config" "github.com/ygelfand/plexctl/internal/ui" ) +func TestBuildPlayRequestRejectsUntrustedPaths(t *testing.T) { + server := config.ServerTarget{URL: "https://plex.invalid", Token: "private-token"} + for _, tt := range []struct { + name string + path string + subtitle bool + }{ + {name: "userinfo authority", path: "@attacker.invalid/video"}, + {name: "absolute URL", path: "https://attacker.invalid/video"}, + {name: "network path", path: "//attacker.invalid/video"}, + {name: "fragment", path: "/video#fragment"}, + {name: "subtitle authority", path: "@attacker.invalid/subtitle", subtitle: true}, + } { + t.Run(tt.name, func(t *testing.T) { + part := components.Part{Key: tt.path} + if tt.subtitle { + part.Key = "/video" + part.Stream = []components.Stream{{StreamType: components.StreamTypeSubtitle, Key: tt.path}} + } + metadata := &components.Metadata{Type: "movie", Media: []components.Media{{Part: []components.Part{part}}}} + req, err := BuildPlayRequest(server.Context(t.Context()), metadata, true) + if err == nil || req != nil { + t.Fatalf("untrusted media path reached playback: request=%+v, error=%v", req, err) + } + if strings.Contains(err.Error(), server.Token) { + t.Fatal("path error exposed the server token") + } + }) + } +} + func TestSharedResumeAndProgressKeepOriginatingServer(t *testing.T) { var reports int friend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/tui/player/manager.go b/internal/tui/player/manager.go index 2675e9e..2528d33 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -79,7 +79,9 @@ func (pm *PlayerManager) beginRequest(parent context.Context) context.Context { if pm.requestCancel != nil { pm.requestCancel() } - ctx, cancel := context.WithTimeout(parent, 2*time.Minute) + // This context also owns foreground playback, which may last for hours. + // Metadata resolution and startup impose their own bounded deadlines. + ctx, cancel := context.WithCancel(parent) pm.requestCancel = cancel select { case <-pm.done: @@ -158,6 +160,8 @@ func (pm *PlayerManager) playRequest(ctx context.Context, req *PlayRequest, outp } func (pm *PlayerManager) startRequest(ctx context.Context, req *PlayRequest, output config.VideoOutput, startOffset int64) (PlayerStatus, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() pm.ioMu.Lock() defer pm.ioMu.Unlock() previous := pm.Status() diff --git a/internal/tui/player/manager_test.go b/internal/tui/player/manager_test.go index 4f72461..a6a2021 100644 --- a/internal/tui/player/manager_test.go +++ b/internal/tui/player/manager_test.go @@ -1,12 +1,43 @@ package player import ( + "context" "testing" + "testing/synctest" + "time" + "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" "github.com/ygelfand/plexctl/internal/config" ) +func TestPlaybackRequestOutlivesMetadataDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + pm := newPlayerManager("") + defer pm.Close() + server := config.ServerTarget{URL: "https://plex.invalid", Token: "test-token"} + ctx := pm.beginRequest(server.Context(context.Background())) + metadata := &components.Metadata{Type: "movie", + Media: []components.Media{{Part: []components.Part{{Key: "/video"}}}}} + if _, err := BuildPlayRequest(ctx, metadata, true); err != nil { + t.Fatal(err) + } + // Foreground mpv inherits this context after metadata resolution. + time.Sleep(3 * time.Hour) + if err := ctx.Err(); err != nil { + t.Fatalf("playback expired without a stop or new selection: %v", err) + } + next := pm.beginRequest(context.Background()) + if ctx.Err() != context.Canceled { + t.Fatal("a new selection did not cancel foreground playback") + } + pm.Close() + if next.Err() != context.Canceled { + t.Fatal("closing the manager did not cancel foreground playback") + } + }) +} + func TestQueueEventsReportEveryTrack(t *testing.T) { server := config.ServerTarget{ID: "friend", URL: "https://friend.invalid", Token: "private"} first := PlayItem{URL: "repeated-file", RatingKey: "one", Title: "First", Duration: 3} diff --git a/internal/tui/player/terminal.go b/internal/tui/player/terminal.go index c514b83..e01d83a 100644 --- a/internal/tui/player/terminal.go +++ b/internal/tui/player/terminal.go @@ -179,6 +179,9 @@ func (p *terminalPlayback) run() error { } ctx, cancel := context.WithCancel(request) defer cancel() + startup, stopStartup := context.WithTimeout(ctx, 2*time.Minute) + defer stopStartup() + startupDone := startup.Done() for _, stream := range []any{p.stdin, p.stdout} { if f, ok := stream.(interface{ Fd() uintptr }); ok && !term.IsTerminal(int(f.Fd())) { return fmt.Errorf("terminal video requires an interactive terminal") @@ -189,7 +192,7 @@ func (p *terminalPlayback) run() error { p.sharedMemory = caps.KittySharedMemory if p.preference != config.VideoWindow { var err error - p.mpv, p.output, err = terminalOutput(ctx, p.preference, os.Getenv) + p.mpv, p.output, err = terminalOutput(startup, p.preference, os.Getenv) if err != nil { return err } @@ -322,7 +325,7 @@ func (p *terminalPlayback) run() error { } }() - conn, err := connectTerminal(ctx, socket, done) + conn, err := connectTerminal(startup, socket, done) if err != nil { return err } @@ -373,7 +376,7 @@ func (p *terminalPlayback) run() error { } // Drain acknowledgements while building large artist queues. Otherwise // a full event channel can block mpv's IPC output and our next write. - if err := awaitQueueLoad(ctx, events, &pending); err != nil { + if err := awaitQueueLoad(startup, events, &pending); err != nil { return err } } @@ -482,6 +485,9 @@ func (p *terminalPlayback) run() error { setTrack(&p.status, item) } case "file-loaded": + // The startup deadline must not limit the running video's lifetime. + startupDone = nil + stopStartup() p.started = true loaded = true p.status.Play() @@ -528,6 +534,8 @@ func (p *terminalPlayback) run() error { select { case <-ctx.Done(): return ctx.Err() + case <-startupDone: + return fmt.Errorf("terminal player did not finish starting playback: %w", startup.Err()) case data, ok := <-keys: if !ok { keys = nil diff --git a/internal/tui/player/video_output.go b/internal/tui/player/video_output.go index 38bf325..474654c 100644 --- a/internal/tui/player/video_output.go +++ b/internal/tui/player/video_output.go @@ -21,7 +21,9 @@ func terminalOutput(ctx context.Context, preference config.VideoOutput, getenv f } ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() - help, err := exec.CommandContext(ctx, mpv, "--no-config", "--vo=help").Output() + cmd := exec.CommandContext(ctx, mpv, "--no-config", "--vo=help") + cmd.WaitDelay = time.Second + help, err := cmd.Output() if err != nil { return "", config.VideoWindow, fmt.Errorf("could not list mpv video outputs: %w", err) } diff --git a/internal/tui/player/video_output_integration_test.go b/internal/tui/player/video_output_integration_test.go new file mode 100644 index 0000000..ecd08ee --- /dev/null +++ b/internal/tui/player/video_output_integration_test.go @@ -0,0 +1,52 @@ +//go:build integration && !windows + +package player + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/ygelfand/plexctl/internal/config" +) + +func TestTerminalOutputBoundsInheritedOutputPipe(t *testing.T) { + dir := t.TempDir() + path, pidPath := filepath.Join(dir, "mpv"), filepath.Join(dir, "child.pid") + t.Setenv("PATH", dir) + t.Setenv("MPV_TEST_CHILD_PID", pidPath) + script := "#!/bin/sh\n/bin/sleep 30 &\nprintf '%s' \"$!\" > \"$MPV_TEST_CHILD_PID\"\nprintf 'Available video outputs:\\n tct text\\n'\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + finished := make(chan struct{}) + t.Cleanup(func() { + if data, err := os.ReadFile(pidPath); err == nil { + if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + if child, err := os.FindProcess(pid); err == nil { + _ = child.Kill() + } + } + } + <-finished + }) + go func() { + defer close(finished) + _, _, err := terminalOutput(t.Context(), config.VideoTCT, os.Getenv) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, exec.ErrWaitDelay) { + t.Fatalf("probe did not report its inherited pipe timeout: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("video output probe waited for a descendant's stdout") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 56b7eea..9e0fa5e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -47,10 +47,29 @@ type Controller struct { visualTick time.Time focus paneFocus focusedAlphabet *ui.AlphabetRail + userSwitchID uint64 + userPickerID uint64 + reloadID uint64 } -type switchUserSuccessMsg struct{} -type reloadedDataMsg plex.LoaderResult +type usersLoadedMsg struct { + id uint64 + users []operations.HomeUser + err error +} + +type switchUserSuccessMsg struct { + id uint64 + owner Overlay + homeUser config.HomeUser + invalidPin bool + err error +} +type reloadedDataMsg struct { + id uint64 + data plex.LoaderResult + err error +} func NewController(data plex.LoaderResult) *Controller { cfg := config.Get() @@ -124,6 +143,14 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case view.DataLoadedMsg: + for _, model := range c.tabManager.tabModels { + if msg.Owns(model) { + _, cmd := model.Update(msg) + return c, cmd + } + } + return c, nil case view.SearchIndexProgressMsg, view.SearchIndexFinishedMsg: for _, model := range c.tabManager.tabModels { if status, ok := model.(*view.SearchStatusView); ok { @@ -204,6 +231,17 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ui.UserSelectionMsg: return c, c.navigator.Push(userpicker.NewUserPickerOverlayModel(msg.Users, c.theme)) + case usersLoadedMsg: + if msg.id != c.userPickerID { + return c, nil + } + if msg.err != nil { + return c, c.alert.NewAlertCmd("error", msg.err.Error()) + } + if len(msg.users) <= 1 { + return c, nil + } + return c.Update(ui.UserSelectionMsg{Users: msg.users}) case ui.InvalidPinMsg: if overlay := c.navigator.ActiveOverlay(); overlay != nil { @@ -219,12 +257,39 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, c.handleSwitchUser(msg) case switchUserSuccessMsg: - c.navigator.Pop() - // Return a command that triggers a full data reload - return c, c.fullReload() + if msg.id != c.userSwitchID || msg.owner != c.navigator.ActiveOverlay() { + return c, nil + } + if msg.invalidPin { + return c.Update(ui.InvalidPinMsg{}) + } + if msg.err != nil { + return c, c.alert.NewAlertCmd("error", msg.err.Error()) + } + cfg := config.Get() + c.userPickerID++ + cfg.HomeUser = msg.homeUser + if err := cfg.Save(); err != nil { + cmds = append(cmds, c.alert.NewAlertCmd("error", err.Error())) + } + if msg.owner != nil { + c.navigator.Pop() + } + // Retire views with the previous user's credentials before reloading. + c.data = plex.LoaderResult{ServerID: cfg.DefaultServer} + c.tabManager.activeTabIdx = 0 + c.returnTabIdx = -1 + cmds = append(cmds, c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth), c.fullReload()) + return c, tea.Batch(cmds...) case reloadedDataMsg: - c.data = plex.LoaderResult(msg) + if msg.id != c.reloadID { + return c, nil + } + if msg.err != nil { + return c, c.alert.NewAlertCmd("error", msg.err.Error()) + } + c.data = msg.data return c, c.tabManager.setupTabs(c.data, c.theme, ui.SidebarWidth) } @@ -369,6 +434,9 @@ func (c *Controller) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { server := config.MediaServer(msg.Server) + if !server.Shared && server.CacheNamespace() != config.Get().MediaCacheNamespace() { + return nil + } returnTabIdx := c.tabManager.activeTabIdx if msg.SectionID != "" || server.Shared { return func() tea.Msg { @@ -407,6 +475,9 @@ func (c *Controller) handleSelectMedia(msg ui.SelectMediaMsg) tea.Cmd { } func (c *Controller) handleJumpToDetail(msg ui.JumpToDetailMsg) tea.Cmd { + if msg.Server.ID != "" && !msg.Server.Shared && msg.Server.CacheNamespace() != config.Get().MediaCacheNamespace() { + return nil + } if msg.Server.Shared { friends := c.tabManager.tabModels[c.tabManager.friendsIdx].(*view.FriendsView) c.returnTabIdx = -1 @@ -443,14 +514,18 @@ func (c *Controller) handleJumpToDetail(msg ui.JumpToDetailMsg) tea.Cmd { } func (c *Controller) handleSwitchUser(msg ui.SwitchUserMsg) tea.Cmd { + c.userSwitchID++ + id, owner := c.userSwitchID, c.navigator.ActiveOverlay() + serverID, _, _ := config.Get().GetActiveServer() + client, clientErr := plex.NewHomeHomeUserClient() return func() tea.Msg { - cfg := config.Get() - - // 1. Get client using home user auth token to perform the switch - client, err := plex.NewHomeHomeUserClient() - if err != nil { - return err + result := switchUserSuccessMsg{id: id, owner: owner} + if clientErr != nil { + result.err = clientErr + return result } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() slog.Info("TUI: Switching user", "target", msg.User.Title, "uuid", msg.User.UUID) @@ -463,37 +538,41 @@ func (c *Controller) handleSwitchUser(msg ui.SwitchUserMsg) tea.Cmd { } } - res, err := client.SDK.HomeUsers.SwitchUser(context.Background(), req) + res, err := client.SDK.HomeUsers.SwitchUser(ctx, req) if err != nil { // Check for 403/Forbidden which indicates invalid PIN if strings.Contains(err.Error(), "403") || strings.Contains(strings.ToLower(err.Error()), "pin is required") { - return ui.InvalidPinMsg{} + result.invalidPin = true + return result } slog.Error("TUI: Switch user failed", "error", err) - return err + result.err = err + return result } - if res.UserPlexAccount == nil { - return fmt.Errorf("failed to switch user: no account data returned") + if res.UserPlexAccount == nil || res.UserPlexAccount.AuthToken == "" { + result.err = fmt.Errorf("failed to switch user: no account data returned") + return result } // 2. We got an AuthToken. Now we need to use THIS specific AuthToken to get resources slog.Info("TUI: Fetching server-specific token for home user") - newClient, err := plex.NewClientWithToken(res.UserPlexAccount.AuthToken) + newClient, err := plex.NewAccountClientWithToken(res.UserPlexAccount.AuthToken) if err != nil { - return err + result.err = err + return result } - resources, err := newClient.SDK.Plex.GetServerResources(context.Background(), operations.GetServerResourcesRequest{ + resources, err := newClient.SDK.Plex.GetServerResources(ctx, operations.GetServerResourcesRequest{ IncludeHTTPS: operations.IncludeHTTPSTrue.ToPointer(), }) if err != nil { slog.Error("TUI: Failed to get server resources for home user", "error", err) - return err + result.err = err + return result } - serverID, _, _ := cfg.GetActiveServer() - accessToken := res.UserPlexAccount.AuthToken // Fallback to auth token if server-specific one isn't found + accessToken := "" for _, dev := range resources.PlexDevices { if dev.ClientIdentifier == serverID && dev.AccessToken != "" { slog.Info("TUI: Found server-specific token", "server", dev.Name) @@ -501,51 +580,58 @@ func (c *Controller) handleSwitchUser(msg ui.SwitchUserMsg) tea.Cmd { break } } + if accessToken == "" { + result.err = fmt.Errorf("selected user does not have access to this server") + return result + } - // 3. Save both tokens - cfg.HomeUser.AuthToken = res.UserPlexAccount.AuthToken - cfg.HomeUser.AccessToken = accessToken - cfg.HomeUser.ServerID = serverID - _ = cfg.Save() - - slog.Info("TUI: User switched successfully") - return switchUserSuccessMsg{} + result.homeUser = config.HomeUser{AuthToken: res.UserPlexAccount.AuthToken, AccessToken: accessToken, ServerID: serverID} + return result } } func (c *Controller) triggerUserSwitch() tea.Cmd { + c.userPickerID++ + id := c.userPickerID + client, clientErr := plex.NewHomeHomeUserClient() return func() tea.Msg { - client, err := plex.NewHomeHomeUserClient() - if err != nil { - return err + if clientErr != nil { + return usersLoadedMsg{id: id, err: clientErr} } - res, err := client.SDK.HomeUsers.GetHomeUsers(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + res, err := client.SDK.HomeUsers.GetHomeUsers(ctx) if err != nil { - return err + return usersLoadedMsg{id: id, err: err} } if res.Object == nil { - return fmt.Errorf("no home data returned") + return usersLoadedMsg{id: id, err: fmt.Errorf("no home data returned")} } - if len(res.Object.Users) <= 1 { - slog.Debug("TUI: 0 or 1 home user, skipping switch") - return nil - } - return ui.UserSelectionMsg{Users: res.Object.Users} + return usersLoadedMsg{id: id, users: res.Object.Users} } } func (c *Controller) fullReload() tea.Cmd { + c.reloadID++ + id, server := c.reloadID, config.Get().ServerTarget() return func() tea.Msg { + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 2*time.Minute) + defer cancel() updates := make(chan any, 10) - go plex.LoadData(context.Background(), updates) + go plex.LoadData(ctx, updates) for { - msg := <-updates + var msg any + select { + case msg = <-updates: + case <-ctx.Done(): + return reloadedDataMsg{id: id, err: ctx.Err()} + } if res, ok := msg.(plex.LoaderResult); ok { - return reloadedDataMsg(res) + return reloadedDataMsg{id: id, data: res} } if err, ok := msg.(error); ok { - return err + return reloadedDataMsg{id: id, err: err} } } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 35dabd5..ae2c52b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,12 +1,18 @@ package tui import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/LukeHagar/plexgo/models/components" "github.com/LukeHagar/plexgo/models/operations" tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/plex" "github.com/ygelfand/plexctl/internal/tui/player" "github.com/ygelfand/plexctl/internal/tui/view" "github.com/ygelfand/plexctl/internal/tui/widget/poster" @@ -14,6 +20,77 @@ import ( "go.dalton.dog/bubbleup" ) +func TestLateSessionResponseUpdatesItsInactiveTab(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status/sessions" { + t.Errorf("unexpected request: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"MediaContainer":{"Metadata":[{"title":"Movie","User":{"title":"owner"},"Player":{"title":"mpv","state":"playing"},"Session":{"id":"session-id"}}]}}`) + })) + defer server.Close() + cfg := config.Get() + old := *cfg + t.Cleanup(func() { *cfg = old }) + t.Setenv("PLEXCTL_TOKEN", "test-token") + cfg.DefaultServer = "session-test" + cfg.Servers = map[string]config.Server{"session-test": {URL: server.URL}} + sessions, tasks := view.NewSessionsTab(ui.PlexctlTheme), view.NewTasksTab(ui.PlexctlTheme) + c := focusController() + c.tabManager.tabModels = []tea.Model{sessions, tasks} + ui.GetLayout().Update(160, 30, false) + sessions.Update(tea.WindowSizeMsg{Width: 160, Height: 30}) + tasks.Update(tea.WindowSizeMsg{Width: 160, Height: 30}) + request := sessions.Init() + c.tabManager.activeTabIdx = 1 + c.Update(request()) + if !strings.Contains(sessions.View(), "owner") { + t.Fatalf("inactive Sessions did not receive its response: %s", sessions.View()) + } + if strings.Contains(tasks.View(), "owner") || strings.Contains(tasks.View(), "session-id") { + t.Fatal("session response appeared in Tasks") + } +} + +func TestSupersededUserSwitchAndReloadCannotReplaceCurrentProfile(t *testing.T) { + c := focusController() + c.userSwitchID = 2 + c.reloadID = 2 + c.data = plex.LoaderResult{ServerID: "current"} + original := config.Get().HomeUser + c.Update(switchUserSuccessMsg{id: 1, homeUser: config.HomeUser{AuthToken: "stale"}}) + c.Update(reloadedDataMsg{id: 1, data: plex.LoaderResult{ServerID: "stale"}}) + if config.Get().HomeUser != original || c.data.ServerID != "current" { + t.Fatal("a superseded user switch changed the current profile") + } + c.Update(switchUserSuccessMsg{id: 2, owner: &mouseOverlay{}, homeUser: config.HomeUser{AuthToken: "closed-picker"}}) + if config.Get().HomeUser != original { + t.Fatal("a closed user picker changed the current profile") + } +} + +func TestSupersededUserPickerResponseDoesNotReopenOverlay(t *testing.T) { + c := focusController() + c.userPickerID = 2 + c.Update(usersLoadedMsg{id: 1, users: []operations.HomeUser{{Title: "Old user"}, {Title: "Another user"}}}) + if c.navigator.ActiveOverlay() != nil { + t.Fatal("superseded user discovery opened an overlay") + } +} + +func TestLateNavigationCannotOpenPreviousProfilesMedia(t *testing.T) { + c := focusController() + previous := config.ServerTarget{ID: "previous-server", Token: "previous-user"} + if cmd := c.handleSelectMedia(ui.SelectMediaMsg{Server: previous, RatingKey: "1"}); cmd != nil { + t.Fatal("stale selection started a metadata request") + } + c.tabManager.tabModels[1] = view.NewMediaView("1", "Movies", ui.PlexctlTheme) + c.Update(ui.JumpToDetailMsg{Server: previous, SectionID: "1", RatingKey: "1", Type: "movie"}) + if c.tabManager.activeTabIdx != 0 { + t.Fatal("late metadata result navigated into the replacement profile") + } +} + func TestPosterNavigationKeysReachContent(t *testing.T) { for _, tt := range []struct { name string diff --git a/internal/tui/view/detail/detail_shared.go b/internal/tui/view/detail/detail_shared.go index a3421b5..953c05c 100644 --- a/internal/tui/view/detail/detail_shared.go +++ b/internal/tui/view/detail/detail_shared.go @@ -1,17 +1,12 @@ package detail import ( - "bytes" "context" "fmt" - "image" "log/slog" "strings" "time" - _ "image/jpeg" - _ "image/png" - "github.com/LukeHagar/plexgo/models/components" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" @@ -111,7 +106,7 @@ func fetchPoster(metadata *components.Metadata, width int, servers ...config.Ser slog.Debug("fetchPoster image fetched", "duration", time.Since(start)) start = time.Now() - img, _, err := image.Decode(bytes.NewReader(data)) + img, err := artwork.Decode(data) if err != nil { slog.Error("fetchPoster decode failed", "error", err) return nil diff --git a/internal/tui/view/friends.go b/internal/tui/view/friends.go index 639521e..24596e9 100644 --- a/internal/tui/view/friends.go +++ b/internal/tui/view/friends.go @@ -64,8 +64,9 @@ func (v *FriendsView) Refresh() tea.Cmd { v.requestID = friendsRequestID.Add(1) v.loading, v.err = true, nil id := v.requestID + server := config.Get().ServerTarget() return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 15*time.Second) defer cancel() servers, err := plex.DiscoverServers(ctx) var shared []components.PlexDevice diff --git a/internal/tui/view/friends_test.go b/internal/tui/view/friends_test.go index ab96df8..1b54cbe 100644 --- a/internal/tui/view/friends_test.go +++ b/internal/tui/view/friends_test.go @@ -26,7 +26,11 @@ func TestFriendsBrowseSearchAndBackKeepOwnedServer(t *testing.T) { cfg.DefaultViewMode = config.ViewModeList ui.GetLayout().Update(120, 40, false) friend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Plex-Token") != "friend-token" { + wantToken := "friend-token" + if r.URL.Path == "/identity" { + wantToken = "" + } + if r.Header.Get("X-Plex-Token") != wantToken { t.Error("used owned credentials for a friend") http.Error(w, "denied", http.StatusUnauthorized) return diff --git a/internal/tui/view/history.go b/internal/tui/view/history.go index 2de49eb..e02985d 100644 --- a/internal/tui/view/history.go +++ b/internal/tui/view/history.go @@ -23,6 +23,9 @@ type HistoryTab struct { metadata []operations.ListPlaybackHistoryMetadata dayCursor time.Time isLoading bool + hasMore bool + request viewRequest + err error } func NewHistoryTab(theme tint.Tint) *HistoryTab { @@ -38,20 +41,24 @@ func NewHistoryTab(theme tint.Tint) *HistoryTab { return &HistoryTab{ table: ui.NewTable(columns, theme), dayCursor: time.Now(), + hasMore: true, + request: newViewRequest(), } } func (t *HistoryTab) Init() tea.Cmd { - t.isLoading = true - return t.fetchNextPage + if t.isLoading || len(t.metadata) > 0 || !t.hasMore { + return nil + } + return t.fetchNextPage() } func (t *HistoryTab) Refresh() tea.Cmd { t.dayCursor = time.Now() t.metadata = nil t.table.SetRows(nil) - t.isLoading = true - return t.fetchNextPage + t.hasMore, t.err = true, nil + return t.fetchNextPage() } func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -67,17 +74,26 @@ func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.table.SetWidth(t.width) case ui.ThemeChangedMsg: ui.UpdateTableTheme(&t.table, t.width, t.height) - case historyResult: + case DataLoadedMsg: + if !t.request.accepts(t, msg) { + return t, nil + } t.isLoading = false - t.metadata = append(t.metadata, msg.metadata...) - newRows := append(t.table.Rows(), msg.rows...) + t.err = msg.err + if msg.err != nil { + return t, nil + } + result := msg.data.(historyResult) + t.dayCursor = result.nextDay + t.hasMore = len(result.rows) > 0 + t.metadata = append(t.metadata, result.metadata...) + newRows := append(t.table.Rows(), result.rows...) t.table.SetRows(newRows) // If we still don't have enough rows to fill the table height, get more tableHeight := ui.GetTableHeight(t.height) - if len(newRows) < tableHeight && len(msg.rows) > 0 && t.height > 0 { - t.isLoading = true - return t, t.fetchNextPage + if len(newRows) < tableHeight && t.hasMore && t.height > 0 { + return t, t.fetchNextPage() } case tea.KeyMsg: switch msg.String() { @@ -92,6 +108,7 @@ func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return t, func() tea.Msg { return ui.SelectMediaMsg{ + Server: t.request.server, RatingKey: *meta.RatingKey, Type: *meta.Type, SectionID: sectionID, @@ -107,9 +124,8 @@ func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.table, cmd = t.table.Update(msg) // Lazy load - if !t.isLoading && t.table.Cursor() > len(t.table.Rows())-10 && len(t.table.Rows()) > 0 { - t.isLoading = true - return t, tea.Batch(cmd, t.fetchNextPage) + if !t.isLoading && t.hasMore && t.err == nil && t.table.Cursor() > len(t.table.Rows())-10 && len(t.table.Rows()) > 0 { + return t, tea.Batch(cmd, t.fetchNextPage()) } return t, cmd @@ -118,33 +134,43 @@ func (t *HistoryTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { type historyResult struct { rows []table.Row metadata []operations.ListPlaybackHistoryMetadata + nextDay time.Time +} + +func (t *HistoryTab) fetchNextPage() tea.Cmd { + t.isLoading = true + cursor := t.dayCursor + return t.request.start(t, func(ctx context.Context) (any, error) { + return fetchHistory(ctx, cursor) + }) } -func (t *HistoryTab) fetchNextPage() tea.Msg { - store, err := plex.GetStore() +func fetchHistory(ctx context.Context, cursor time.Time) (any, error) { + store, err := plex.GetStoreForContext(ctx) if err != nil { - return err + return nil, err } - ctx := context.Background() - // Try fetching days until we find data or hit a limit (e.g. 30 empty days) var allRows []table.Row var allMeta []operations.ListPlaybackHistoryMetadata for range 30 { - gte := t.dayCursor.Add(-24 * time.Hour) - lte := t.dayCursor + gte := cursor.Add(-24 * time.Hour) + lte := cursor res, err := store.ListPlaybackHistory(ctx, operations.ListPlaybackHistoryRequest{ Sort: []string{"viewedAt:desc"}, ViewedAtGte: ui.Ptr(gte.Unix()), - ViewedAtLte: ui.Ptr(lte.Unix()), + ViewedAtLte: ui.Ptr(lte.Unix() - 1), }) - t.dayCursor = gte + if err != nil { + return nil, err + } + cursor = gte - if err == nil && res.Object != nil && res.Object.MediaContainer != nil { + if res.Object != nil && res.Object.MediaContainer != nil { userMap := make(map[int64]string) libMap := make(map[string]string) deviceMap := make(map[string]string) @@ -178,10 +204,14 @@ func (t *HistoryTab) fetchNextPage() tea.Msg { return historyResult{ rows: allRows, metadata: allMeta, - } + nextDay: cursor, + }, nil } func (t *HistoryTab) View() string { + if t.err != nil { + return fmt.Sprintf("Unable to load history: %v\n[r] Retry", t.err) + } if len(t.table.Rows()) == 0 { return lipgloss.NewStyle(). Padding(2). diff --git a/internal/tui/view/history_test.go b/internal/tui/view/history_test.go index 6f420cd..292b5f5 100644 --- a/internal/tui/view/history_test.go +++ b/internal/tui/view/history_test.go @@ -15,7 +15,7 @@ func TestHistoryTableNavigationPreservesActivation(t *testing.T) { for i := range metadata { metadata[i] = operations.ListPlaybackHistoryMetadata{RatingKey: ui.Ptr(fmt.Sprint(i)), Type: ui.Ptr("movie")} } - tab.Update(historyResult{rows: rows, metadata: metadata}) + tab.Update(DataLoadedMsg{owner: tab, data: historyResult{rows: rows, metadata: metadata}}) verifyTableNavigation(t, tab, &tab.table) _, cmd := tab.Update(ui.KeyPress("enter")) if cmd == nil { diff --git a/internal/tui/view/home.go b/internal/tui/view/home.go index 80106b7..dc39294 100644 --- a/internal/tui/view/home.go +++ b/internal/tui/view/home.go @@ -2,6 +2,7 @@ package view import ( "context" + "fmt" "github.com/LukeHagar/plexgo/models/components" "github.com/charmbracelet/bubbles/viewport" @@ -20,6 +21,8 @@ type HomeView struct { activeList int theme tint.Tint loading bool + err error + request viewRequest details detail.DetailManager viewport viewport.Model @@ -27,14 +30,13 @@ type HomeView struct { func NewHomeView(theme tint.Tint) *HomeView { return &HomeView{ + request: newViewRequest(), theme: theme, loading: true, viewport: viewport.New(0, 0), } } -type homeHubsMsg []components.Hub - func (v *HomeView) CanJumpLetters() bool { target, ok := v.details.View.(ui.LetterJumper) return ok && target.CanJumpLetters() @@ -55,7 +57,7 @@ func (v *HomeView) JumpToLetter(letter string) tea.Cmd { } func (v *HomeView) Init() tea.Cmd { - return v.fetchHubs + return v.Refresh() } func (v *HomeView) Refresh() tea.Cmd { @@ -65,24 +67,23 @@ func (v *HomeView) Refresh() tea.Cmd { } } v.loading = true - return v.fetchHubs -} - -func (v *HomeView) fetchHubs() tea.Msg { - hubs, err := plex.GetHomeHubs(context.Background()) - if err != nil { - return err - } - return homeHubsMsg(hubs) + v.err = nil + return v.request.start(v, func(ctx context.Context) (any, error) { + return plex.GetHomeHubs(ctx) + }) } func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if result, ok := msg.(DataLoadedMsg); ok && !v.request.accepts(v, result) { + return v, nil + } if posterMsg, ok := msg.(poster.PosterLoadedMsg); ok && v.details.Active() { for _, list := range v.lists { list.Update(posterMsg) } } - if v.details.Active() { + _, homeResult := msg.(DataLoadedMsg) + if v.details.Active() && !homeResult { cmd, handled := v.details.Update(msg) if handled { return v, cmd @@ -127,9 +128,13 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { l.Theme = msg.Theme } shouldRedraw = true - case homeHubsMsg: + case DataLoadedMsg: v.loading = false - v.hubs = msg + v.err = msg.err + if msg.err != nil { + return v, nil + } + v.hubs = msg.data.([]components.Hub) var newLists []*poster.PosterList for i, hub := range v.hubs { @@ -152,7 +157,7 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, existing.SetItems(hub.Metadata)) newLists = append(newLists, existing) } else { - l := poster.NewPosterList(i, title, hub.Metadata, v.theme) + l := poster.NewPosterList(i, title, hub.Metadata, v.theme, v.request.server) newLists = append(newLists, l) cmds = append(cmds, l.Init()) } @@ -184,13 +189,13 @@ func (v *HomeView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var dv tea.Model switch meta.Type { case "show": - dv = detail.NewShowDetailView(ratingKey, v.theme) + dv = detail.NewShowDetailView(ratingKey, v.theme, v.request.server) case "episode": - dv = detail.NewEpisodeDetailView(ratingKey, v.theme) + dv = detail.NewEpisodeDetailView(ratingKey, v.theme, v.request.server) case "artist", "album", "track": - dv = detail.NewMusicDetailView(ratingKey, v.theme) + dv = detail.NewMusicDetailView(ratingKey, v.theme, v.request.server) default: - dv = detail.NewMovieDetailView(ratingKey, v.theme) + dv = detail.NewMovieDetailView(ratingKey, v.theme, v.request.server) } layout := ui.GetLayout() @@ -293,6 +298,9 @@ func (v *HomeView) View() string { if v.loading { return "\n ⌛ Loading Dashboard..." } + if v.err != nil { + return fmt.Sprintf("Unable to load dashboard: %v\n[r] Retry", v.err) + } if len(v.lists) == 0 { return "\n No promoted hubs found." } diff --git a/internal/tui/view/libraries.go b/internal/tui/view/libraries.go index e6b76e9..e642137 100644 --- a/internal/tui/view/libraries.go +++ b/internal/tui/view/libraries.go @@ -17,9 +17,11 @@ type LibrarySelectedMsg struct { } type LibrariesTab struct { - table ui.Table - width int - height int + table ui.Table + width int + height int + request viewRequest + err error } func NewLibrariesTab(theme tint.Tint) *LibrariesTab { @@ -31,31 +33,33 @@ func NewLibrariesTab(theme tint.Tint) *LibrariesTab { } return &LibrariesTab{ - table: ui.NewTable(columns, theme), + request: newViewRequest(), + table: ui.NewTable(columns, theme), } } func (t *LibrariesTab) Init() tea.Cmd { - return t.fetchLibraries + return t.Refresh() } func (t *LibrariesTab) Refresh() tea.Cmd { - return t.fetchLibraries + t.err = nil + return t.request.start(t, fetchLibraries) } -func (t *LibrariesTab) fetchLibraries() tea.Msg { - client, err := plex.NewClient() +func fetchLibraries(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } - res, err := client.SDK.Library.GetSections(context.Background()) + res, err := client.SDK.Library.GetSections(ctx) if err != nil { - return err + return nil, err } if res.Object == nil || res.Object.MediaContainer == nil { - return fmt.Errorf("no libraries found") + return nil, fmt.Errorf("no libraries found") } var rows []table.Row @@ -81,7 +85,7 @@ func (t *LibrariesTab) fetchLibraries() tea.Msg { }) } - return rows + return rows, nil } func (t *LibrariesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -97,8 +101,14 @@ func (t *LibrariesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.table.SetWidth(t.width) case ui.ThemeChangedMsg: ui.UpdateTableTheme(&t.table, t.width, t.height) - case []table.Row: - t.table.SetRows(msg) + case DataLoadedMsg: + if !t.request.accepts(t, msg) { + return t, nil + } + t.err = msg.err + if msg.err == nil { + t.table.SetRows(msg.data.([]table.Row)) + } case tea.KeyMsg: switch msg.String() { case "enter": @@ -121,5 +131,8 @@ func (t *LibrariesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (t *LibrariesTab) View() string { + if t.err != nil { + return fmt.Sprintf("Unable to load libraries: %v\n[r] Retry", t.err) + } return t.table.View() } diff --git a/internal/tui/view/libraries_test.go b/internal/tui/view/libraries_test.go index f3381d3..98d15e1 100644 --- a/internal/tui/view/libraries_test.go +++ b/internal/tui/view/libraries_test.go @@ -8,6 +8,6 @@ import ( func TestLibrariesTableNavigation(t *testing.T) { tab := NewLibrariesTab(ui.PlexctlTheme) - tab.Update(navigationRows(len(tab.table.Columns()))) + tab.Update(DataLoadedMsg{owner: tab, data: navigationRows(len(tab.table.Columns()))}) verifyTableNavigation(t, tab, &tab.table) } diff --git a/internal/tui/view/requests.go b/internal/tui/view/requests.go new file mode 100644 index 0000000..2a462c2 --- /dev/null +++ b/internal/tui/view/requests.go @@ -0,0 +1,44 @@ +package view + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/ygelfand/plexctl/internal/config" +) + +// DataLoadedMsg follows its originating view even if the user changes tabs. +// Replaced views and superseded refreshes cannot consume the result. +type DataLoadedMsg struct { + owner tea.Model + id uint64 + data any + err error +} + +func (m DataLoadedMsg) Owns(model tea.Model) bool { return m.owner == model } + +type viewRequest struct { + server config.ServerTarget + id uint64 +} + +func newViewRequest() viewRequest { + return viewRequest{server: config.Get().ServerTarget()} +} + +func (r *viewRequest) start(owner tea.Model, load func(context.Context) (any, error)) tea.Cmd { + r.id++ + id, server := r.id, r.server + return func() tea.Msg { + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 30*time.Second) + defer cancel() + data, err := load(ctx) + return DataLoadedMsg{owner: owner, id: id, data: data, err: err} + } +} + +func (r *viewRequest) accepts(owner tea.Model, msg DataLoadedMsg) bool { + return msg.Owns(owner) && msg.id == r.id +} diff --git a/internal/tui/view/requests_test.go b/internal/tui/view/requests_test.go new file mode 100644 index 0000000..f5ffbf4 --- /dev/null +++ b/internal/tui/view/requests_test.go @@ -0,0 +1,78 @@ +package view + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/charmbracelet/bubbles/table" + "github.com/ygelfand/plexctl/internal/config" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestTableResultsStayWithTheirViewAndRefresh(t *testing.T) { + sessions := NewSessionsTab(ui.PlexctlTheme) + tasks := NewTasksTab(ui.PlexctlTheme) + oldSessions := NewSessionsTab(ui.PlexctlTheme) + sessions.Refresh() + oldID := sessions.request.id + sessions.Refresh() + result := DataLoadedMsg{owner: sessions, id: sessions.request.id, data: []table.Row{{"session", "user", "movie", "player", "playing"}}} + tasks.Update(result) + oldSessions.Update(result) + if len(tasks.table.Rows()) != 0 || len(oldSessions.table.Rows()) != 0 { + t.Fatal("session data was accepted by another tab or view instance") + } + sessions.Update(result) + result.id, result.data = oldID, []table.Row{{"stale", "", "", "", ""}} + sessions.Update(result) + if got := sessions.table.Rows()[0][0]; got != "session" { + t.Fatalf("old refresh replaced newer rows: %q", got) + } +} + +func TestHomeRejectsPreviousViewAndShowsLoadErrors(t *testing.T) { + old := NewHomeView(ui.PlexctlTheme) + current := NewHomeView(ui.PlexctlTheme) + current.Update(DataLoadedMsg{owner: old, data: []components.Hub{{Title: ui.Ptr("Private hub")}}}) + if len(current.hubs) != 0 { + t.Fatal("new Home accepted the old user's hubs") + } + current.Update(DataLoadedMsg{owner: current, err: errors.New("unavailable")}) + if current.loading || current.err == nil { + t.Fatal("failed Home request stayed stuck in loading state") + } +} + +func TestViewRequestRetainsServerWhenCommandIsDelayed(t *testing.T) { + view := NewSessionsTab(ui.PlexctlTheme) + view.request.server = config.ServerTarget{ID: "original", Token: "original-user"} + cmd := view.request.start(view, func(ctx context.Context) (any, error) { + return config.ServerFromContext(ctx), nil + }) + view.request.server = config.ServerTarget{ID: "replacement", Token: "new-user"} + result := cmd().(DataLoadedMsg) + if target := result.data.(config.ServerTarget); target.ID != "original" || target.Token != "original-user" { + t.Fatal("delayed request used replacement credentials") + } +} + +func TestHistoryDoesNotDuplicatePendingPageOrAcceptOldCursor(t *testing.T) { + history := NewHistoryTab(ui.PlexctlTheme) + if history.Init() == nil || history.Init() != nil { + t.Fatal("History did not retain its pending request across tab activation") + } + oldID := history.request.id + history.Refresh() + cursor := history.dayCursor + history.Update(DataLoadedMsg{owner: history, id: oldID, data: historyResult{nextDay: cursor.Add(-24 * time.Hour)}}) + if !history.dayCursor.Equal(cursor) || !history.isLoading { + t.Fatal("old page changed the refreshed History cursor or loading state") + } + history.Update(DataLoadedMsg{owner: history, id: history.request.id, err: errors.New("unavailable")}) + if history.isLoading || history.err == nil || !history.dayCursor.Equal(cursor) { + t.Fatal("failed page skipped history or left loading stuck") + } +} diff --git a/internal/tui/view/search_status.go b/internal/tui/view/search_status.go index 15679c4..db67d5f 100644 --- a/internal/tui/view/search_status.go +++ b/internal/tui/view/search_status.go @@ -53,9 +53,10 @@ func (v *SearchStatusView) Refresh() tea.Cmd { v.progress = search.IndexProgress{} v.progressChan = make(chan search.IndexProgress, 100) progress := v.progressChan + index := search.GetIndex() return tea.Batch(v.spinner.Tick, func() tea.Msg { defer close(progress) - err := search.GetIndex().Reindex(context.Background(), progress) + err := index.Reindex(context.Background(), progress) return SearchIndexFinishedMsg{owner: v, err: err} }, v.waitForProgress()) } diff --git a/internal/tui/view/sessions.go b/internal/tui/view/sessions.go index f8d03c0..d043af3 100644 --- a/internal/tui/view/sessions.go +++ b/internal/tui/view/sessions.go @@ -3,6 +3,7 @@ package view import ( "context" "encoding/json" + "fmt" "io" "log/slog" @@ -16,9 +17,11 @@ import ( ) type SessionsTab struct { - table ui.Table - width int - height int + table ui.Table + width int + height int + request viewRequest + err error } func NewSessionsTab(theme tint.Tint) *SessionsTab { @@ -31,16 +34,18 @@ func NewSessionsTab(theme tint.Tint) *SessionsTab { } return &SessionsTab{ - table: ui.NewTable(columns, theme), + request: newViewRequest(), + table: ui.NewTable(columns, theme), } } func (t *SessionsTab) Init() tea.Cmd { - return t.fetchSessions + return t.Refresh() } func (t *SessionsTab) Refresh() tea.Cmd { - return t.fetchSessions + t.err = nil + return t.request.start(t, fetchSessions) } type sessionData struct { @@ -63,15 +68,15 @@ type sessionsResponse struct { } `json:"MediaContainer"` } -func (t *SessionsTab) fetchSessions() tea.Msg { - client, err := plex.NewClient() +func fetchSessions(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } - res, err := client.SDK.Status.ListSessions(context.Background()) + res, err := client.SDK.Status.ListSessions(ctx) if err != nil { - return err + return nil, err } defer func() { @@ -81,12 +86,12 @@ func (t *SessionsTab) fetchSessions() tea.Msg { }() body, err := io.ReadAll(res.RawResponse.Body) if err != nil { - return err + return nil, err } var sRes sessionsResponse if err := json.Unmarshal(body, &sRes); err != nil { - return err + return nil, err } var rows []table.Row @@ -100,11 +105,7 @@ func (t *SessionsTab) fetchSessions() tea.Msg { }) } - if len(rows) == 0 { - return []table.Row{} - } - - return rows + return rows, nil } func (t *SessionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -117,8 +118,14 @@ func (t *SessionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.table.SetWidth(t.width) case ui.ThemeChangedMsg: ui.UpdateTableTheme(&t.table, t.width, t.height) - case []table.Row: - t.table.SetRows(msg) + case DataLoadedMsg: + if !t.request.accepts(t, msg) { + return t, nil + } + t.err = msg.err + if msg.err == nil { + t.table.SetRows(msg.data.([]table.Row)) + } case tea.KeyMsg: switch msg.String() { case "s": @@ -137,26 +144,29 @@ func (t *SessionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (t *SessionsTab) stopSession(id string) tea.Cmd { - return func() tea.Msg { - client, err := plex.NewClient() + return t.request.start(t, func(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } reason := "Terminated via plexctl TUI" - _, err = client.SDK.Status.TerminateSession(context.Background(), operations.TerminateSessionRequest{ + _, err = client.SDK.Status.TerminateSession(ctx, operations.TerminateSessionRequest{ SessionID: id, Reason: &reason, }) if err != nil { - return err + return nil, err } - return t.fetchSessions() - } + return fetchSessions(ctx) + }) } func (t *SessionsTab) View() string { + if t.err != nil { + return fmt.Sprintf("Unable to load sessions: %v\n[r] Retry", t.err) + } if len(t.table.Rows()) == 0 { return lipgloss.NewStyle(). Padding(2). diff --git a/internal/tui/view/sessions_test.go b/internal/tui/view/sessions_test.go index 2367c06..0e38148 100644 --- a/internal/tui/view/sessions_test.go +++ b/internal/tui/view/sessions_test.go @@ -55,6 +55,6 @@ func verifyTableNavigation(t *testing.T, model tea.Model, table *ui.Table) { func TestSessionsTableNavigation(t *testing.T) { tab := NewSessionsTab(ui.PlexctlTheme) - tab.Update(navigationRows(len(tab.table.Columns()))) + tab.Update(DataLoadedMsg{owner: tab, data: navigationRows(len(tab.table.Columns()))}) verifyTableNavigation(t, tab, &tab.table) } diff --git a/internal/tui/view/tasks.go b/internal/tui/view/tasks.go index 368b22b..b033934 100644 --- a/internal/tui/view/tasks.go +++ b/internal/tui/view/tasks.go @@ -14,9 +14,11 @@ import ( ) type TasksTab struct { - table ui.Table - width int - height int + table ui.Table + width int + height int + request viewRequest + err error } func NewTasksTab(theme tint.Tint) *TasksTab { @@ -28,31 +30,33 @@ func NewTasksTab(theme tint.Tint) *TasksTab { } return &TasksTab{ - table: ui.NewTable(columns, theme), + request: newViewRequest(), + table: ui.NewTable(columns, theme), } } func (t *TasksTab) Init() tea.Cmd { - return t.fetchTasks + return t.Refresh() } func (t *TasksTab) Refresh() tea.Cmd { - return t.fetchTasks + t.err = nil + return t.request.start(t, fetchTasks) } -func (t *TasksTab) fetchTasks() tea.Msg { - client, err := plex.NewClient() +func fetchTasks(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } - res, err := client.SDK.Butler.GetTasks(context.Background()) + res, err := client.SDK.Butler.GetTasks(ctx) if err != nil { - return err + return nil, err } if res.Object == nil || res.Object.ButlerTasks == nil { - return fmt.Errorf("no butler tasks found") + return nil, fmt.Errorf("no butler tasks found") } var rows []table.Row @@ -85,7 +89,7 @@ func (t *TasksTab) fetchTasks() tea.Msg { }) } - return rows + return rows, nil } func (t *TasksTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -98,8 +102,14 @@ func (t *TasksTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.table.SetWidth(t.width) case ui.ThemeChangedMsg: ui.UpdateTableTheme(&t.table, t.width, t.height) - case []table.Row: - t.table.SetRows(msg) + case DataLoadedMsg: + if !t.request.accepts(t, msg) { + return t, nil + } + t.err = msg.err + if msg.err == nil { + t.table.SetRows(msg.data.([]table.Row)) + } case tea.KeyMsg: switch msg.String() { case "s": // Start task @@ -122,38 +132,41 @@ func (t *TasksTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (t *TasksTab) startTask(name string) tea.Cmd { - return func() tea.Msg { - client, err := plex.NewClient() + return t.request.start(t, func(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } - _, err = client.SDK.Butler.StartTask(context.Background(), operations.StartTaskRequest{ + _, err = client.SDK.Butler.StartTask(ctx, operations.StartTaskRequest{ ButlerTask: operations.PathParamButlerTask(name), }) if err != nil { - return err + return nil, err } - return t.fetchTasks() - } + return fetchTasks(ctx) + }) } func (t *TasksTab) stopTask(name string) tea.Cmd { - return func() tea.Msg { - client, err := plex.NewClient() + return t.request.start(t, func(ctx context.Context) (any, error) { + client, err := plex.NewClientForContext(ctx) if err != nil { - return err + return nil, err } - _, err = client.SDK.Butler.StopTask(context.Background(), operations.StopTaskRequest{ + _, err = client.SDK.Butler.StopTask(ctx, operations.StopTaskRequest{ ButlerTask: operations.ButlerTask(name), }) if err != nil { - return err + return nil, err } - return t.fetchTasks() - } + return fetchTasks(ctx) + }) } func (t *TasksTab) View() string { + if t.err != nil { + return fmt.Sprintf("Unable to load tasks: %v\n[r] Retry", t.err) + } if len(t.table.Rows()) == 0 { return lipgloss.NewStyle(). Padding(2). diff --git a/internal/tui/view/tasks_test.go b/internal/tui/view/tasks_test.go index c9a9c35..0e43070 100644 --- a/internal/tui/view/tasks_test.go +++ b/internal/tui/view/tasks_test.go @@ -8,6 +8,6 @@ import ( func TestTasksTableNavigation(t *testing.T) { tab := NewTasksTab(ui.PlexctlTheme) - tab.Update(navigationRows(len(tab.table.Columns()))) + tab.Update(DataLoadedMsg{owner: tab, data: navigationRows(len(tab.table.Columns()))}) verifyTableNavigation(t, tab, &tab.table) } diff --git a/internal/tui/widget/poster/grid.go b/internal/tui/widget/poster/grid.go index 9c4005a..b209b2c 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -206,8 +206,9 @@ func (m *PosterGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "enter": if m.Cursor >= 0 && m.Cursor < len(m.Items) { + metadata := m.Items[m.Cursor].Metadata return m, func() tea.Msg { - return ItemSelectedMsg{Metadata: m.Items[m.Cursor].Metadata} + return ItemSelectedMsg{Metadata: metadata} } } } diff --git a/internal/tui/widget/poster/item.go b/internal/tui/widget/poster/item.go index e4ead8d..fca5ea3 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -1,16 +1,11 @@ package poster import ( - "bytes" "context" "fmt" - "image" "strings" "time" - _ "image/jpeg" - _ "image/png" - "github.com/LukeHagar/plexgo/models/components" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -82,7 +77,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata, servers .. return result } - img, _, err := image.Decode(bytes.NewReader(data)) + img, err := artwork.Decode(data) if err != nil { return result } @@ -100,10 +95,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata, servers .. func RenderPosterItem(item *PosterItem, isSelected bool, active bool, theme tint.Tint) string { content := item.Poster if item.Loading || content == "" { - title := item.Metadata.Title - if len(title) > ui.PosterWidth-2 { - title = title[:ui.PosterWidth-5] + "..." - } + title := ui.Ellipsis(item.Metadata.Title, ui.PosterWidth-2) content = "\n\n" + title } @@ -180,7 +172,7 @@ func GetDisplayTitle(meta *components.Metadata) string { func RenderMiniWatched(meta *components.Metadata, theme tint.Tint) string { if meta.ViewOffset != nil && *meta.ViewOffset > 0 && meta.Duration != nil && *meta.Duration > 0 { - percent := float64(*meta.ViewOffset) / float64(*meta.Duration) + percent := min(1.0, float64(*meta.ViewOffset)/float64(*meta.Duration)) width := ui.PosterWidth - 2 filled := int(float64(width) * percent) if filled < 1 && percent > 0 { diff --git a/internal/tui/widget/poster/item_test.go b/internal/tui/widget/poster/item_test.go new file mode 100644 index 0000000..aa91654 --- /dev/null +++ b/internal/tui/widget/poster/item_test.go @@ -0,0 +1,29 @@ +package poster + +import ( + "math" + "testing" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/charmbracelet/lipgloss" + "github.com/ygelfand/plexctl/internal/ui" +) + +func TestMiniWatchedBoundsMalformedOffsets(t *testing.T) { + for _, tt := range []struct { + name string + offset, duration int + }{ + {"in progress", 5, 10}, + {"past end", 100, 10}, + {"huge offset", math.MaxInt, 1}, + } { + t.Run(tt.name, func(t *testing.T) { + meta := &components.Metadata{ViewOffset: &tt.offset, Duration: &tt.duration} + bar := RenderMiniWatched(meta, ui.PlexctlTheme) + if width := lipgloss.Width(bar); width != ui.PosterWidth-2 { + t.Fatalf("metadata generated a %d-cell bar, want %d", width, ui.PosterWidth-2) + } + }) + } +} diff --git a/internal/tui/widget/poster/list.go b/internal/tui/widget/poster/list.go index 5407915..168a415 100644 --- a/internal/tui/widget/poster/list.go +++ b/internal/tui/widget/poster/list.go @@ -143,8 +143,9 @@ func (m *PosterList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": if m.Cursor >= 0 && m.Cursor < len(m.Items) { + metadata := m.Items[m.Cursor].Metadata return m, func() tea.Msg { - return ItemSelectedMsg{Metadata: m.Items[m.Cursor].Metadata} + return ItemSelectedMsg{Metadata: metadata} } } case "left", "h": diff --git a/internal/tui/widget/poster/list_test.go b/internal/tui/widget/poster/list_test.go index 61efe3c..283962c 100644 --- a/internal/tui/widget/poster/list_test.go +++ b/internal/tui/widget/poster/list_test.go @@ -19,3 +19,25 @@ func TestPosterListResizeKeepsSelectionVisible(t *testing.T) { t.Fatalf("resize left selection %d outside the row beginning at %d", m.Cursor, m.Offset) } } + +func TestPosterSelectionRetainsItemWhenListChangesBeforeDispatch(t *testing.T) { + items := []components.Metadata{{Title: "Selected"}, {Title: "Other"}} + list := NewPosterList(0, "Movies", items, ui.PlexctlTheme) + list.Active = true + grid := NewPosterGrid(items, ui.PlexctlTheme) + for _, model := range []tea.Model{list, grid} { + _, cmd := model.Update(ui.KeyPress("enter")) + if cmd == nil { + t.Fatal("selection did not produce a command") + } + switch model := model.(type) { + case *PosterList: + model.SetItems(nil) + case *PosterGrid: + model.SetItems(nil) + } + if selection := cmd().(ItemSelectedMsg); selection.Metadata.Title != "Selected" { + t.Fatal("queued selection changed after the list was refreshed") + } + } +}