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 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/README.md b/README.md index 144a51b..1912391 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,42 @@ 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 +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 +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. +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 @@ -35,24 +65,131 @@ 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 +``` + +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 - **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. +- **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 ### 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, +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. 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 +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+, +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. + +**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 +list/poster views. + +**Ctrl+H/J/K/L** moves focus left/down/up/right between the sidebar, library, +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 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 +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 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 +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, +**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. + ### 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. @@ -77,6 +214,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 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/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 0b100c1..78a3157 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "math" + "strconv" "strings" "time" @@ -25,31 +27,22 @@ var historyCmd = &cobra.Command{ var viewedAt *int64 if since != "" { - d, err := time.ParseDuration(since) + d, err := parseHistoryDuration(since) if err != nil { - if strings.HasSuffix(since, "d") { - val := strings.TrimSuffix(since, "d") - 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") - 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 } 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 + } } } @@ -64,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 + } } } @@ -93,6 +88,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/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/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 cf98014..2b710fb 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -4,11 +4,15 @@ import ( "context" "fmt" "log/slog" + "os" + "os/signal" + "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 +29,25 @@ 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 + } 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() + } 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) @@ -37,10 +60,10 @@ var playCmd = &cobra.Command{ if trailer { slog.Debug("CLI Play: Resolving trailer", "mediaID", mediaID) - playFunc = player.FetchAndPlayTrailer(meta, tctMode) + playFunc = player.FetchAndPlayTrailer(ctx, meta, output) } else { offset := int64(0) - if !noResume && meta.ViewOffset != nil { + if !noResume && meta.Type != "artist" && meta.Type != "album" && meta.ViewOffset != nil { offset = int64(*meta.ViewOffset) } @@ -50,7 +73,7 @@ var playCmd = &cobra.Command{ slog.Info("Playing", "title", meta.Title, "type", meta.Type) } - playFunc = player.PlayMedia(meta, false, tctMode, offset) + playFunc = player.PlayMedia(ctx, meta, false, output, offset) } if playFunc == nil { @@ -58,27 +81,38 @@ 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 + } 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) + 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 } } @@ -89,7 +123,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..a679368 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -38,10 +39,9 @@ var rootCmd = &cobra.Command{ }, RunE: func(cmd *cobra.Command, args []string) error { if config.Get().DefaultToTui { - return runTUI() + return runTUI(cmd) } - cmd.Help() - return nil + return cmd.Help() }, } @@ -68,13 +68,31 @@ 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)") + 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 + })) } func initConfig() { @@ -101,17 +119,24 @@ 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() + 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 { ui.RenderError(fmt.Errorf("failed to parse config: %w", err)) os.Exit(1) } + if err := cfg.ValidatePlayback(); err != nil { + ui.RenderError(err) + os.Exit(1) + } // Ensure flags override config if outputType != "" && outputType != "table" { @@ -137,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/search.go b/cmd/search.go index 4a9c3e5..f8e6e79 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{}{ + data := map[string]any{ "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/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/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/tui.go b/cmd/tui.go index 6fd6204..eeb1526 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -2,8 +2,10 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" + "os" "path/filepath" "time" @@ -12,7 +14,10 @@ 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" + "github.com/ygelfand/plexctl/internal/tui/player" ) var tuiCmd = &cobra.Command{ @@ -20,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() 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 @@ -38,6 +46,23 @@ 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(), "shared_memory", caps.KittySharedMemory, "tmux", termgraphics.InTmux()) + restore, err := termgraphics.EnablePassthrough() + if err != nil { + slog.Debug("Graphics passthrough unavailable", "error", err) + } + defer restore() + output, err := artwork.OpenRenderer(os.Stdout, cfg.PosterRenderer, caps) + if err != nil { + return fmt.Errorf("initialize poster graphics: %w", err) + } + 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) @@ -47,7 +72,14 @@ 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()) + defer func() { + pm := player.GetPlayerManager() + if pm.Status().AudioOnly || pm.Status().Overlay || cfg.CloseVideoOnQuit { + pm.StopPlayback()() + } + pm.Close() + }() if _, err := p.Run(); err != nil { slog.Error("TUI: Program run failed", "error", err) 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/content/cli/plexctl.md b/docs/content/cli/plexctl.md index 82c8bcf..436603e 100644 --- a/docs/content/cli/plexctl.md +++ b/docs/content/cli/plexctl.md @@ -13,12 +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 - -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 @@ -30,6 +35,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_collection.md b/docs/content/cli/plexctl_collection.md index 321cf95..122ab10 100644 --- a/docs/content/cli/plexctl_collection.md +++ b/docs/content/cli/plexctl_collection.md @@ -11,11 +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 - -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 aeb73f6..e28f69a 100644 --- a/docs/content/cli/plexctl_collection_list.md +++ b/docs/content/cli/plexctl_collection_list.md @@ -15,11 +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 - -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 547c4e2..4237f50 100644 --- a/docs/content/cli/plexctl_collection_show.md +++ b/docs/content/cli/plexctl_collection_show.md @@ -15,11 +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 - -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 d61cf7f..66358ad 100644 --- a/docs/content/cli/plexctl_device.md +++ b/docs/content/cli/plexctl_device.md @@ -11,11 +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 - -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 5a41cac..bd87b75 100644 --- a/docs/content/cli/plexctl_device_list.md +++ b/docs/content/cli/plexctl_device_list.md @@ -15,11 +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 - -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 50609dd..a0b311f 100644 --- a/docs/content/cli/plexctl_device_show.md +++ b/docs/content/cli/plexctl_device_show.md @@ -15,11 +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 - -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 9abaf0d..8e6434b 100644 --- a/docs/content/cli/plexctl_history.md +++ b/docs/content/cli/plexctl_history.md @@ -16,11 +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 - -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 d12bff0..ffc62b7 100644 --- a/docs/content/cli/plexctl_homeusers.md +++ b/docs/content/cli/plexctl_homeusers.md @@ -11,11 +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 - -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 f7d7f0b..39ee6d4 100644 --- a/docs/content/cli/plexctl_homeusers_list.md +++ b/docs/content/cli/plexctl_homeusers_list.md @@ -15,11 +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 - -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 945db66..a769b0c 100644 --- a/docs/content/cli/plexctl_hub.md +++ b/docs/content/cli/plexctl_hub.md @@ -11,11 +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 - -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 537cc48..bcdcaf1 100644 --- a/docs/content/cli/plexctl_hub_list.md +++ b/docs/content/cli/plexctl_hub_list.md @@ -15,11 +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 - -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 e0d7281..d66536e 100644 --- a/docs/content/cli/plexctl_library.md +++ b/docs/content/cli/plexctl_library.md @@ -11,11 +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 - -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 d9ba146..952b9c8 100644 --- a/docs/content/cli/plexctl_library_list.md +++ b/docs/content/cli/plexctl_library_list.md @@ -15,11 +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 - -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 b93124e..7e0de27 100644 --- a/docs/content/cli/plexctl_library_refresh.md +++ b/docs/content/cli/plexctl_library_refresh.md @@ -15,11 +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 - -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 ca24704..5b03b24 100644 --- a/docs/content/cli/plexctl_library_show.md +++ b/docs/content/cli/plexctl_library_show.md @@ -9,17 +9,25 @@ 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") + --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 4df994c..87611b4 100644 --- a/docs/content/cli/plexctl_login.md +++ b/docs/content/cli/plexctl_login.md @@ -16,11 +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 - -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 new file mode 100644 index 0000000..da1daab --- /dev/null +++ b/docs/content/cli/plexctl_mpv.md @@ -0,0 +1,33 @@ +## 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") + --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 + +* [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..84683ca --- /dev/null +++ b/docs/content/cli/plexctl_mpv_install.md @@ -0,0 +1,32 @@ +## 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") + --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 + +* [plexctl mpv](plexctl_mpv.md) - Check the installed mpv and latest stable release diff --git a/docs/content/cli/plexctl_play.md b/docs/content/cli/plexctl_play.md index 9f7347a..285e706 100644 --- a/docs/content/cli/plexctl_play.md +++ b/docs/content/cli/plexctl_play.md @@ -11,17 +11,23 @@ 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") + --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 741a2e4..eabaadf 100644 --- a/docs/content/cli/plexctl_playlist.md +++ b/docs/content/cli/plexctl_playlist.md @@ -11,11 +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 - -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 25ff315..3a0fa4b 100644 --- a/docs/content/cli/plexctl_playlist_list.md +++ b/docs/content/cli/plexctl_playlist_list.md @@ -15,11 +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 - -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 7171462..f3d131c 100644 --- a/docs/content/cli/plexctl_playlist_show.md +++ b/docs/content/cli/plexctl_playlist_show.md @@ -15,11 +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 - -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 de245e1..945785c 100644 --- a/docs/content/cli/plexctl_search.md +++ b/docs/content/cli/plexctl_search.md @@ -11,11 +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 - -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 7de4031..53966a7 100644 --- a/docs/content/cli/plexctl_search_find.md +++ b/docs/content/cli/plexctl_search_find.md @@ -15,11 +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 - -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 016f379..dadc31f 100644 --- a/docs/content/cli/plexctl_search_reindex.md +++ b/docs/content/cli/plexctl_search_reindex.md @@ -15,11 +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 - -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 8e05ce0..c771fb5 100644 --- a/docs/content/cli/plexctl_search_status.md +++ b/docs/content/cli/plexctl_search_status.md @@ -15,11 +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 - -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 fa31fb9..a250c4e 100644 --- a/docs/content/cli/plexctl_server.md +++ b/docs/content/cli/plexctl_server.md @@ -11,17 +11,22 @@ 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") + --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 * [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 acd88d0..6ab41a6 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] @@ -15,11 +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 - -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 782a83c..6ba34a5 100644 --- a/docs/content/cli/plexctl_server_list.md +++ b/docs/content/cli/plexctl_server_list.md @@ -15,11 +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 - -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 27f408a..9092ab8 100644 --- a/docs/content/cli/plexctl_server_status.md +++ b/docs/content/cli/plexctl_server_status.md @@ -15,11 +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 - -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 ee83d3e..5836a6f 100644 --- a/docs/content/cli/plexctl_server_use.md +++ b/docs/content/cli/plexctl_server_use.md @@ -15,11 +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 - -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 83eaed3..41b222a 100644 --- a/docs/content/cli/plexctl_session.md +++ b/docs/content/cli/plexctl_session.md @@ -11,11 +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 - -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 78553a3..fbf1f31 100644 --- a/docs/content/cli/plexctl_session_list.md +++ b/docs/content/cli/plexctl_session_list.md @@ -15,11 +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 - -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 7653dbf..f4bba0e 100644 --- a/docs/content/cli/plexctl_session_show.md +++ b/docs/content/cli/plexctl_session_show.md @@ -15,11 +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 - -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 16cf2cc..5a6e0ed 100644 --- a/docs/content/cli/plexctl_session_stop.md +++ b/docs/content/cli/plexctl_session_stop.md @@ -15,11 +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 - -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 657a6f9..c1ed316 100644 --- a/docs/content/cli/plexctl_tasks.md +++ b/docs/content/cli/plexctl_tasks.md @@ -11,11 +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 - -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 7a8dfdb..72d3a72 100644 --- a/docs/content/cli/plexctl_tasks_list.md +++ b/docs/content/cli/plexctl_tasks_list.md @@ -15,11 +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 - -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 d4cf9f3..87192b6 100644 --- a/docs/content/cli/plexctl_tasks_start.md +++ b/docs/content/cli/plexctl_tasks_start.md @@ -15,11 +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 - -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 6bcf3d7..2903cdb 100644 --- a/docs/content/cli/plexctl_tasks_stop.md +++ b/docs/content/cli/plexctl_tasks_stop.md @@ -15,11 +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 - -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 e247479..71f5832 100644 --- a/docs/content/cli/plexctl_tui.md +++ b/docs/content/cli/plexctl_tui.md @@ -15,11 +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 - -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 5132d52..e6f9304 100644 --- a/docs/content/cli/plexctl_users.md +++ b/docs/content/cli/plexctl_users.md @@ -11,11 +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 - -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 8805625..94c46f2 100644 --- a/docs/content/cli/plexctl_users_list.md +++ b/docs/content/cli/plexctl_users_list.md @@ -15,11 +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 - -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 8fc6814..1342853 100644 --- a/docs/content/cli/plexctl_version.md +++ b/docs/content/cli/plexctl_version.md @@ -15,11 +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 - -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 2e729fe..f211567 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 @@ -16,6 +16,13 @@ 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 +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: @@ -69,17 +76,114 @@ 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 Settings related to how media is played and how data is stored locally. +### `terminal_video` +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`. +- **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. 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. + +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. @@ -133,11 +237,13 @@ 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 (`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. -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/index.mdx b/docs/content/index.mdx index 2a1eac5..6d8fb79 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. @@ -50,10 +51,25 @@ 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. +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 @@ -79,4 +95,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/docs/content/tui.mdx b/docs/content/tui.mdx index 4ec25ad..6f57531 100644 --- a/docs/content/tui.mdx +++ b/docs/content/tui.mdx @@ -5,43 +5,296 @@ 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. - **`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: + +- **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 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. + ## 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 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. -- **`u`**: Switch User (Plex Home). +- **`H`** (uppercase): Jump directly to Home tab. Lowercase **`h`** moves left within the current view. - **`?`**: Show help overlay. +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. + +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. The footer and highlighted border or bracketed +letter identify the focused pane. Clicking a pane focuses it too. Dialogs retain +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 -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: +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. ### 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 +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, 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. + +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. + +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. +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 +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 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 | +| --- | --- | +| 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 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: 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. + +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. - 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. 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 +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 +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. + +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. +- **`Left` / `Right`** or **wheel**: Seek backward or forward. +- **`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 +with terminal playback. Use **x** for windowed or overlay playback and **q** for +suspended playback. + +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. 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 +``` + +The preference is stored as `terminal_video` in `~/.plexctl.yaml` and can also be +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 +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; +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 + +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/docs/package-lock.json b/docs/package-lock.json index 93b77d7..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" } }, @@ -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" @@ -688,9 +688,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "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" ], @@ -706,13 +706,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "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" ], @@ -728,20 +728,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "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.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -751,9 +751,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "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" ], @@ -767,9 +767,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "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" ], @@ -783,9 +783,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "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" ], @@ -802,9 +802,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "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" ], @@ -821,9 +821,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "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" ], @@ -840,9 +840,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "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" ], @@ -859,9 +859,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "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" ], @@ -878,9 +878,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "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" ], @@ -897,9 +897,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "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" ], @@ -916,9 +916,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "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" ], @@ -935,9 +935,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "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" ], @@ -956,13 +956,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "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" ], @@ -981,13 +981,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "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" ], @@ -1006,13 +1006,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "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" ], @@ -1031,13 +1031,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "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" ], @@ -1056,13 +1056,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "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" ], @@ -1081,13 +1081,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "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" ], @@ -1106,13 +1106,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "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" ], @@ -1131,13 +1131,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "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": { @@ -1151,16 +1151,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "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.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1170,9 +1170,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "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" ], @@ -1189,9 +1189,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "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" ], @@ -1208,9 +1208,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "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" ], @@ -1259,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" }, @@ -1602,18 +1602,19 @@ } }, "node_modules/@next/env": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", - "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", + "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.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.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": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, @@ -1635,9 +1636,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", - "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", + "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" ], @@ -1651,9 +1652,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", - "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", + "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" ], @@ -1667,9 +1668,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", - "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", + "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" ], @@ -1686,9 +1687,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", - "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", + "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" ], @@ -1705,9 +1706,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", - "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", + "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" ], @@ -1724,9 +1725,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", - "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", + "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" ], @@ -1743,9 +1744,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", - "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", + "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" ], @@ -1759,9 +1760,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", - "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", + "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" ], @@ -2441,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" @@ -2464,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": { @@ -2480,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": { @@ -2500,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": { @@ -2523,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" @@ -2545,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": { @@ -2558,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" @@ -2582,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": { @@ -2601,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" @@ -2625,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.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "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" @@ -2674,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" @@ -2696,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", @@ -2994,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" @@ -3024,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": { @@ -3329,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" @@ -3376,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": [ { @@ -3396,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" @@ -3470,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", @@ -4414,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" }, @@ -4741,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.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.1.6", + "@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", "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", @@ -5009,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": { @@ -5025,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": { @@ -8267,9 +8255,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -8317,12 +8305,12 @@ } }, "node_modules/next": { - "version": "16.3.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", - "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", + "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.3.4", + "@next/env": "16.4.2", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -8336,15 +8324,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.4", - "@next/swc-darwin-x64": "16.3.4", - "@next/swc-linux-arm64-gnu": "16.3.4", - "@next/swc-linux-arm64-musl": "16.3.4", - "@next/swc-linux-x64-gnu": "16.3.4", - "@next/swc-linux-x64-musl": "16.3.4", - "@next/swc-win32-arm64-msvc": "16.3.4", - "@next/swc-win32-x64-msvc": "16.3.4", - "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", @@ -8469,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": { @@ -9788,9 +9776,9 @@ } }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "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": { @@ -9805,31 +9793,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@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": { @@ -9984,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" @@ -10400,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": { @@ -10588,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" @@ -10607,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": { @@ -10842,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": [ { 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 3bb5d44..71c59d2 100644 --- a/go.mod +++ b/go.mod @@ -1,86 +1,84 @@ 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/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 + 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 + 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 ) 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 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect -) - -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/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/ansi v0.11.4 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect + 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 + 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 // indirect + 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 @@ -89,12 +87,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 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.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 + 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 b4318b9..9d5a2f4 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= @@ -38,8 +40,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= @@ -176,29 +176,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.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/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/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.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.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.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= +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= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 19f9ee7..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 @@ -63,7 +77,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() @@ -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 @@ -114,7 +123,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") } @@ -130,23 +139,18 @@ func (m *Manager) Get(key string, val interface{}) 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 2eefa45..6055c86 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" @@ -20,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"), @@ -30,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 } @@ -41,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 @@ -60,77 +62,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/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 new file mode 100644 index 0000000..25f18b4 --- /dev/null +++ b/internal/config/auth.go @@ -0,0 +1,54 @@ +package config + +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 _, 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 + } + if c.HomeUser.AccessToken != "" && (c.HomeUser.ServerID == "" || c.HomeUser.ServerID == c.DefaultServer) { + return c.HomeUser.AccessToken + } + 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 new file mode 100644 index 0000000..a469c9f --- /dev/null +++ b/internal/config/auth_test.go @@ -0,0 +1,62 @@ +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") + } +} + +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() != "" || 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() != "" { + 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 4cc097b..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 ( @@ -87,32 +89,43 @@ 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 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"` + 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 @@ -142,13 +155,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, + 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 @@ -202,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) @@ -214,20 +249,60 @@ 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("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) 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. @@ -245,25 +320,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/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/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/server_target.go b/internal/config/server_target.go new file mode 100644 index 0000000..db50ba5 --- /dev/null +++ b/internal/config/server_target.go @@ -0,0 +1,51 @@ +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:"-"` + 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(), + AccountToken: c.AccountToken(), 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.URL+"\x00"+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/config/video.go b/internal/config/video.go new file mode 100644 index 0000000..4f20a7c --- /dev/null +++ b/internal/config/video.go @@ -0,0 +1,53 @@ +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" + // AudioTerminal keeps the TUI active and disables mpv's video window. + AudioTerminal VideoOutput = "audio" +) + +// 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) + } +} + +// 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 new file mode 100644 index 0000000..4be2853 --- /dev/null +++ b/internal/config/video_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +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) + } + }) + } +} + +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/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 new file mode 100644 index 0000000..2e725fb --- /dev/null +++ b/internal/mpvsetup/check.go @@ -0,0 +1,166 @@ +// 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) + } + return inspect(ctx, path) +} + +func inspect(ctx context.Context, path string) (Status, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + 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) + } + 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 +} + +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"` +} + +// 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 !stableVersion(cached.Version) { + 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 !stableVersion(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_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/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..adbeb2e --- /dev/null +++ b/internal/mpvsetup/install.go @@ -0,0 +1,205 @@ +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 + 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"): + privileged = false + action := "upgrade" + if missing { + action = "install" + } + 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"}} + 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, apt: apt}, 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 + } + // 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) + } + 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) { + 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 + } + _, _ = 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/mpvsetup/process_linux.go b/internal/mpvsetup/process_linux.go new file mode 100644 index 0000000..e27b999 --- /dev/null +++ b/internal/mpvsetup/process_linux.go @@ -0,0 +1,22 @@ +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 + // 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..c9d09dc --- /dev/null +++ b/internal/mpvsetup/process_other.go @@ -0,0 +1,16 @@ +//go:build !linux + +package mpvsetup + +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 new file mode 100644 index 0000000..0b0fe43 --- /dev/null +++ b/internal/mpvsetup/source.go @@ -0,0 +1,320 @@ +package mpvsetup + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "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 := buildContext(ctx) + 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..7595ab7 --- /dev/null +++ b/internal/mpvsetup/source_integration_test.go @@ -0,0 +1,345 @@ +//go:build integration && linux + +package mpvsetup + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "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) + 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 & + 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") + } +} + +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/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/plex/artwork_auth_test.go b/internal/plex/artwork_auth_test.go new file mode 100644 index 0000000..c758024 --- /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.Header.Get("X-Plex-Token") != "home+user&token" || r.URL.Query().Has("X-Plex-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..81ba2ca 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -3,10 +3,7 @@ package plex import ( "context" "fmt" - "log/slog" "net/http" - "net/http/httputil" - "os" "runtime" "time" @@ -24,74 +21,61 @@ 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) { - cfg := config.Get() + return NewClientWithToken(config.Get().ServerToken()) +} - // 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" - } - } +// NewAccountClient uses the active Plex account or Home profile for plex.tv. +func NewAccountClient() (*Client, error) { + return NewAccountClientWithToken(config.Get().AccountToken()) +} - slog.Debug("NewClient: Initializing with token", "source", source) - return NewClientWithToken(token) +// 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, "") } -// NewHomeUserClient specifically uses the V2 AuthToken (general user token) +// 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") @@ -101,7 +85,8 @@ func NewClientWithToken(token string) (*Client, error) { base: http.DefaultTransport, cfg: cfg, }, - Timeout: 60 * time.Second, + Timeout: 60 * time.Second, + CheckRedirect: sameOriginRedirect, } opts := []plexgo.SDKOption{ @@ -116,8 +101,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 @@ -125,5 +110,6 @@ func NewClientWithToken(token 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 6018d29..007954f 100644 --- a/internal/plex/loader.go +++ b/internal/plex/loader.go @@ -35,42 +35,50 @@ 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{}) { - sendUpdate := func(p float64, msg string) { - if updates != nil { - updates <- ProgressUpdate{Percent: p, Message: msg} +func LoadData(ctx context.Context, updates chan<- any) { + 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, serverID, 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<- interface{}) { 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<- interface{}) { 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<- interface{}) { var libInfo LibraryInfo req := operations.ListContentRequest{SectionID: key} - err = cache.AutoCache(cm, serverID, 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<- interface{}) { 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 b02a5e2..d05c641 100644 --- a/internal/plex/media.go +++ b/internal/plex/media.go @@ -2,12 +2,11 @@ package plex import ( "context" - "encoding/json" "fmt" "io" "log/slog" "net/http" - "strings" + neturl "net/url" "time" "github.com/LukeHagar/plexgo/models/components" @@ -56,8 +55,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") } @@ -66,7 +65,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 } @@ -82,7 +81,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 @@ -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") } @@ -109,64 +108,14 @@ 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 { - 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", serverID, 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) - separator := "?" - if strings.Contains(url, "?") { - separator = "&" - } - url = fmt.Sprintf("%s%sX-Plex-Token=%s", url, separator, cfg.Token) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - req.Header.Set("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - 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 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") } @@ -175,32 +124,43 @@ 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) - 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, cfg.Token) - resp, err := http.Get(url) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } - defer resp.Body.Close() + req.Header.Set("X-Plex-Token", server.Token) + resp, err := mediaHTTPClient().Do(req) + if err != nil { + return nil, err + } + 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) } - 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) @@ -209,7 +169,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/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 new file mode 100644 index 0000000..34e32e3 --- /dev/null +++ b/internal/plex/music.go @@ -0,0 +1,211 @@ +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 + } + 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 + } + 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 := mediaHTTPClient().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/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/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..fb0e06b --- /dev/null +++ b/internal/plex/playable_test.go @@ -0,0 +1,74 @@ +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) { + 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"}}}, + }} + 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) { + 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 { + 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/plex/servers.go b/internal/plex/servers.go new file mode 100644 index 0000000..f50151a --- /dev/null +++ b/internal/plex/servers.go @@ -0,0 +1,146 @@ +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 := NewAccountClientWithToken(config.ServerFromContext(ctx).AccountToken) + 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.Fragment != "" || + (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") + // 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 { + 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..6f8bc80 --- /dev/null +++ b/internal/plex/servers_test.go @@ -0,0 +1,152 @@ +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 TestDiscoveryUsesCapturedAccountTokenAndIncludesFriends(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"} + 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") + } + 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(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) + } +} + +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) + 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") + 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/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/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..a7515c8 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), @@ -38,8 +39,19 @@ func (p *ServerListPresenter) Rows() [][]string { return rows } -func (p *ServerListPresenter) Raw() interface{} { - return p.Devices +func (p *ServerListPresenter) Raw() any { + 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 { @@ -85,7 +97,7 @@ func (p *ServerIdentityPresenter) Rows() [][]string { }} } -func (p *ServerIdentityPresenter) Raw() interface{} { +func (p *ServerIdentityPresenter) Raw() any { return p.Container } @@ -128,8 +140,8 @@ func (p *DevicesPresenter) Rows() [][]string { return rows } -func (p *DevicesPresenter) Raw() interface{} { - return p.Devices +func (p *DevicesPresenter) Raw() any { + return (&ServerListPresenter{Devices: p.Devices}).Raw() } func (p *DevicesPresenter) SortableColumns() []string { @@ -168,7 +180,7 @@ type SessionMetadata struct { type SessionsPresenter struct { Sessions []SessionMetadata - RawData interface{} + RawData any } func (p *SessionsPresenter) Title() string { @@ -187,7 +199,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/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/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/search/index.go b/internal/search/index.go index 5e079ca..7fcb0fc 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,30 +37,64 @@ type SearchIndex struct { LastIndexed time.Time `json:"lastIndexed"` 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 +// 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 ( - indexInstance *SearchIndex - indexOnce sync.Once + indexInstance *SearchIndex + indexMu sync.Mutex + indexNamespace string ) func GetIndex() *SearchIndex { - indexOnce.Do(func() { + indexMu.Lock() + defer indexMu.Unlock() + cfg := config.Get() + namespace := cfg.MediaCacheNamespace() + if indexInstance == nil || indexNamespace != namespace || indexInstance.cacheDir != cfg.CacheDir { indexInstance = &SearchIndex{} + indexInstance.bindScope() + indexNamespace = namespace _ = indexInstance.Load() - }) + } return indexInstance } 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 } - serverID, _, _ := cfg.GetActiveServer() - key := fmt.Sprintf("%s/search_index", serverID) + key := fmt.Sprintf("%s/search_index", idx.server.CacheNamespace()) var data SearchIndex if err := cm.Get(key, &data); err == nil { @@ -69,20 +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 } - serverID, _, _ := cfg.GetActiveServer() - key := fmt.Sprintf("%s/search_index", serverID) + 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 @@ -90,7 +137,11 @@ type IndexProgress struct { } func (idx *SearchIndex) Reindex(ctx context.Context, progress chan<- IndexProgress) error { - client, err := plex.NewClient() + idx.bindScope() + idx.reindexMu.Lock() + defer idx.reindexMu.Unlock() + ctx = idx.server.Context(ctx) + client, err := plex.NewClientForContext(ctx) if err != nil { return err } @@ -114,16 +165,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 +184,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 +197,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) @@ -161,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 @@ -179,39 +230,24 @@ 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) + 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), @@ -220,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), @@ -239,11 +278,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/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/search/live.go b/internal/search/live.go new file mode 100644 index 0000000..0c43c31 --- /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.NewClientForContext(ctx) + 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/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/pane.go b/internal/termgraphics/pane.go new file mode 100644 index 0000000..def32e7 --- /dev/null +++ b/internal/termgraphics/pane.go @@ -0,0 +1,145 @@ +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 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() { + return nil, nil + } + return newPaneZoom(os.Getenv("TMUX_PANE"), func(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "tmux", args...) + cmd.WaitDelay = time.Second + return cmd.Output() + }) +} + +func newPaneZoom(pane string, run func(...string) ([]byte, error)) (*PaneZoom, error) { + p := &PaneZoom{pane: pane, run: run} + initial, err := p.state() + if err != nil { + return nil, err + } + p.initial, p.last = initial, initial + return p, nil +} + +func (p *PaneZoom) Zoomed() (bool, error) { + 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 paneZoomState{}, fmt.Errorf("inspect tmux pane zoom: %w", err) + } + 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 +// 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) { + 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) + } + // tmux leaves a one-pane window unzoomed. Read the actual result instead + // of assuming every successful command changed the state. + 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 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 + } + current, err := p.state() + if err != nil { + return err + } + if current == p.last && current.zoomed != p.initial.zoomed { + if _, err := p.Toggle(); err != nil { + return err + } + } + p.changed = false + return nil +} 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 new file mode 100644 index 0000000..56da650 --- /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_id} #{window_zoomed_flag} #{pane_active}"}): + if zoomed { + return []byte("@1 1 1\n"), nil + } + return []byte("@1 0 1\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 := "@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") + } + return []byte(state), nil + }) + if err != nil { + t.Fatal(err) + } + state = "@1 1 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("@1 0 1"), 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("@1 0 1"), 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 new file mode 100644 index 0000000..118e67f --- /dev/null +++ b/internal/termgraphics/terminal.go @@ -0,0 +1,265 @@ +// Package termgraphics detects terminal graphics and handles tmux passthrough. +package termgraphics + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/muesli/cancelreader" + "golang.org/x/term" +) + +type Capabilities struct { + Responded bool + Kitty bool + // 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 + 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 { + 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("), ")"), ".") + 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() + 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) + } + }) + 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() + 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 { + 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 +} + +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{ + 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:] { + 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 + if match := cellSizeReply.FindStringSubmatch(reply); match != nil { + c.CellHeight, _ = strconv.Atoi(match[1]) + c.CellWidth, _ = strconv.Atoi(match[2]) + } + 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 { + 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 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() + } + 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 + } + } + }() + 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\x1b[16t")) + 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..092d2f0 --- /dev/null +++ b/internal/termgraphics/terminal_test.go @@ -0,0 +1,140 @@ +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) + } + 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) + } + 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") + } + 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}}, + {"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) { + 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} + 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)+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") + } +} + +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) + } + 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") + } + } +} + +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/termgraphics/writer.go b/internal/termgraphics/writer.go new file mode 100644 index 0000000..819224f --- /dev/null +++ b/internal/termgraphics/writer.go @@ -0,0 +1,202 @@ +package termgraphics + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "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 + // Direct observes graphics without wrapping them for tmux. + Direct bool + // 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 + 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. +// 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) (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') + if start < 0 { + start = len(w.pending) + } + if start > 0 { + if _, err := w.buffered.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] == '[' { + 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.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.buffered.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 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") { + sequence = w.kittyFrame(sequence) + } + 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 { + return 0, err + } + w.pending = w.pending[end:] + } + 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..b825448 --- /dev/null +++ b/internal/termgraphics/writer_test.go @@ -0,0 +1,124 @@ +package termgraphics + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "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\\" + 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/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.go b/internal/tui/artwork/artwork.go new file mode 100644 index 0000000..de0434d --- /dev/null +++ b/internal/tui/artwork/artwork.go @@ -0,0 +1,199 @@ +package artwork + +import ( + "fmt" + "image" + "io" + "math/rand/v2" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi/kitty" + "github.com/ygelfand/plexctl/internal/termgraphics" + "golang.org/x/image/draw" +) + +type kittyRenderer struct { + sync.Mutex + dir string + nextID int + views map[string]string + files map[int]string + wanted map[int]bool + sent map[int]uint64 + clock uint64 +} + +func newKittyRenderer() (Renderer, error) { + dir, err := os.MkdirTemp("", "plexctl-posters-") + if err != nil { + return nil, err + } + 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 (*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/%d", ratingKey, width, height) +} + +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 (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 img == nil || img.Bounds().Empty() { + return "", fmt.Errorf("empty poster image") + } + 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 { + 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: cols, Rows: rows, VirtualPlacement: true, + }) + closeErr := f.Close() + if err != nil { + return "", err + } + if closeErr != nil { + return "", closeErr + } + view := padImage(placeholders(id, cols, rows), width, height) + r.files[id] = path + r.views[cacheKey] = view + 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 (r *kittyRenderer) Frame(view string) string { + r.Lock() + defer r.Unlock() + wanted := make(map[int]bool) + for _, m := range imageColor.FindAllStringSubmatch(view, -1) { + red, _ := strconv.Atoi(m[1]) + g, _ := strconv.Atoi(m[2]) + b, _ := strconv.Atoi(m[3]) + id := red<<16 | g<<8 | b + if _, ok := r.files[id]; ok { + wanted[id] = true + } + } + r.wanted = wanted + return view +} + +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(r.sent) + return out.Write(p) + } + 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 := out.Write(data); err != nil { + return 0, err + } + } + r.sent[id] = r.clock + } + // Bound terminal texture memory; inactive posters can be uploaded again. + for len(r.sent) > max(64, len(r.wanted)) { + oldest, age := 0, ^uint64(0) + for id, used := range r.sent { + if !r.wanted[id] && used < age { + oldest, age = id, used + } + } + if oldest == 0 { + break + } + _, _ = io.WriteString(out, deleteImage(oldest)) + delete(r.sent, oldest) + } + 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 (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 new file mode 100644 index 0000000..17de5f2 --- /dev/null +++ b/internal/tui/artwork/artwork_test.go @@ -0,0 +1,105 @@ +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) + } + t.Cleanup(func() { + if err := f.Close(); err != nil { + t.Error(err) + } + }) + w, err := Open(f, true) + if err != nil { + t.Fatal(err) + } + 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) + } + 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(active.renderer.(*kittyRenderer).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) + } + 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") + } + 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 := active.renderer.(*kittyRenderer).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/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/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/focus.go b/internal/tui/focus.go new file mode 100644 index 0000000..b226f78 --- /dev/null +++ b/internal/tui/focus.go @@ -0,0 +1,172 @@ +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 + 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) + } + 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) { + c.validateAlphabetFocus() + switch key { + case "ctrl+h": + if c.focus == paneAlphabet { + c.focusPane(paneContent) + } else { + c.focusPane(paneSidebar) + } + case "ctrl+l": + switch c.focus { + case paneSidebar: + c.focusPane(paneContent) + case paneContent: + c.focusPane(paneAlphabet) + } + case "ctrl+j": + if (c.focus == paneContent || c.focus == paneAlphabet) && 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 == 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 { + 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..c1c1b80 --- /dev/null +++ b/internal/tui/focus_test.go @@ -0,0 +1,229 @@ +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" + "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") + } +} + +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/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/music_player.go b/internal/tui/music_player.go new file mode 100644 index 0000000..1f8b1c7 --- /dev/null +++ b/internal/tui/music_player.go @@ -0,0 +1,182 @@ +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 + } + if config.Get().MusicVisualizer == "waveform" { + return max(2, min(8, (height-16)/3)) + } + 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 c.focus == panePlayer && !c.playerStatus.Active() { + c.focusPane(paneContent) + } + 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) + 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 +} + +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 = "waveform" + case "spectrum": + cfg.MusicVisualizer = "mirror" + default: + cfg.MusicVisualizer = "spectrum" + } + _ = 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()) + 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" { + 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(c.paneBorder(panePlayer)). + 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 c4444df..692bb69 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" ) @@ -39,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] } } @@ -61,6 +65,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..b6b0a6f 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,83 +14,142 @@ 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 { - 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 +} - cfg := config.Get() - _, serverCfg, ok := cfg.GetActiveServer() - if !ok { - return func() tea.Msg { return fmt.Errorf("no active server") } - } +type PlayRequest struct { + Items []PlayItem + Server config.ServerTarget + NoReport bool + Music bool + Subtitles []ExternalSubtitle +} - part := metadata.Media[0].Part[0] - separator := "?" - if strings.Contains(part.Key, "?") { - separator = "&" +// 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") + } + 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 + } } - playURL := fmt.Sprintf("%s%s%sX-Plex-Token=%s", serverCfg.URL, part.Key, separator, cfg.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") + } + 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 + } + 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", serverCfg.URL, stream.Key, sep, cfg.Token) - lang := "" + language := "" if stream.LanguageCode != nil { - lang = *stream.LanguageCode + language = *stream.LanguageCode + } + subtitleURL, err := plex.MediaURL(server, stream.Key) + if err != nil { + return nil, fmt.Errorf("invalid subtitle path: %w", err) } - subtitles = append(subtitles, ExternalSubtitle{ - URL: subURL, - Title: stream.DisplayTitle, - Language: lang, + req.Subtitles = append(req.Subtitles, ExternalSubtitle{ + URL: subtitleURL, Title: stream.DisplayTitle, Language: language, }) } } + return req, nil +} - slog.Debug("Playing media", "url", playURL, "tct", tctMode, "offset", startOffset, "subtitles", len(subtitles)) - rk := "" - if metadata.RatingKey != nil { - rk = *metadata.RatingKey - } +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) +} - title := metadata.Title - if noReport { - title = "Trailer: " + title +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)() } - - return GetPlayerManager().Play(playURL, title, rk, noReport, tctMode, 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, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) + pm := GetPlayerManager() + ctx := pm.beginRequest(server.Context(context.Background())) return func() tea.Msg { - meta, err := plex.GetMetadata(context.Background(), 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 + } if err != nil { return err } - if meta.ViewOffset != nil && *meta.ViewOffset > 0 { - return ui.ResumeChoiceMsg{ + if meta.Type != "artist" && meta.Type != "album" && meta.ViewOffset != nil && *meta.ViewOffset > 0 { + return ui.ResumeChoiceMsg{Server: server, Metadata: meta, - TctMode: tctMode, + Output: output, } } - cmd := PlayMedia(meta, false, tctMode, 0) - 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, tctMode bool) 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 { @@ -115,15 +175,16 @@ func FetchAndPlayTrailer(parentMeta *components.Metadata, tctMode bool) tea.Cmd slog.Debug("FetchAndPlayTrailer: Resolved trailer key", "parent", parentMeta.Title, "target", ratingKey) - meta, err := plex.GetMetadata(context.Background(), 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 + } if err != nil { return err } - cmd := PlayMedia(meta, true, tctMode, 0) - 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..3bf3a72 --- /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 != target.URL+"/new.flac?X-Plex-Token=test-token" { + t.Errorf("obsolete queue reached mpv: %s", mediaURL) + } + default: + return + } + } + }) + } +} diff --git a/internal/tui/player/actions_test.go b/internal/tui/player/actions_test.go new file mode 100644 index 0000000..accdf4f --- /dev/null +++ b/internal/tui/player/actions_test.go @@ -0,0 +1,81 @@ +package player + +import ( + "context" + "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) { + 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/audio_integration_test.go b/internal/tui/player/audio_integration_test.go new file mode 100644 index 0000000..0f08874 --- /dev/null +++ b/internal/tui/player/audio_integration_test.go @@ -0,0 +1,209 @@ +//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) + } + 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", + "--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 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 { + 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 + 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 && 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 || pm.Status().Waveform.Serial != paused.Waveform.Serial { + 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 && + 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 && + 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 || 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) { + 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..6f103ed --- /dev/null +++ b/internal/tui/player/ipc.go @@ -0,0 +1,104 @@ +package player + +import ( + "context" + "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 + events []terminalEvent +} + +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 { + terminalEvent + 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.Event == "start-file" || reply.Event == "end-file" || reply.Event == "file-loaded" { + c.events = append(c.events, reply.terminalEvent) + } + 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 + } +} + +// 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) +} + +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 084ec6a..2528d33 100644 --- a/internal/tui/player/manager.go +++ b/internal/tui/player/manager.go @@ -3,28 +3,48 @@ package player import ( "context" "fmt" - "log/slog" + neturl "net/url" "os" "os/exec" "path/filepath" "runtime" "sync" - "syscall" + "sync/atomic" "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" + "github.com/ygelfand/plexctl/internal/termgraphics" ) type PlayerManager struct { - conn *mpvipc.Connection - socketPath string - status PlayerStatus - stopChan chan struct{} - updates chan tea.Msg + // 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 + 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 ( @@ -34,480 +54,765 @@ 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 - }() +func newPlayerManager(socket string) *PlayerManager { + 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() + } + // 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 ok := <-done: - return ok - case <-time.After(200 * time.Millisecond): - slog.Debug("PlayerManager: connection check timed out") - return false + case <-pm.done: + cancel() + default: } + return ctx } 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, tctMode bool, startOffset int64, subtitles []ExternalSubtitle) tea.Cmd { - return func() tea.Msg { - slog.Debug("PlayerManager.Play start", "title", title, "rk", ratingKey, "tct", tctMode, "offset", startOffset) - if err := pm.ensureMpv(); err != nil { - slog.Error("PlayerManager: mpv check failed", "error", err) - return err - } +func (pm *PlayerManager) setStatus(status PlayerStatus) { + pm.statusMu.Lock() + pm.status = status + pm.statusMu.Unlock() +} - // Strictly validate existing connection - if pm.conn != nil && !pm.VerifyConnection() { - slog.Debug("PlayerManager: cleaning up non-responsive connection") - pm.cleanup() - } +func (pm *PlayerManager) VerifyConnection() bool { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + return pm.conn != nil && pm.conn.get("mpv-version", nil) == nil +} - 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 { - slog.Debug("PlayerManager: different media, reporting progress first") - pm.reportProgressWithKey() - } - } +func (pm *PlayerManager) Play( + url, title, ratingKey string, noReport bool, output config.VideoOutput, + startOffset int64, subtitles []ExternalSubtitle, servers ...config.ServerTarget, +) tea.Cmd { + return pm.PlayRequest(&PlayRequest{ + Items: []PlayItem{{URL: url, Title: title, RatingKey: ratingKey}}, + Server: config.MediaServer(servers...), NoReport: noReport, Subtitles: subtitles, + }, output, startOffset) +} - 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) - } - } +func (pm *PlayerManager) PlayRequest(req *PlayRequest, output config.VideoOutput, startOffset int64) tea.Cmd { + return pm.playRequest(pm.beginRequest(context.Background()), req, output, startOffset) +} - if pm.conn == nil { - if err := pm.spawnMpv(tctMode); err != nil { - slog.Error("PlayerManager: spawn failed", "error", err) - return err - } +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 { + if isOverlayOutput(output) { + _, resolved, err := terminalOutput(ctx, output, os.Getenv) + if err != nil { + return err } + output = resolved } - - pm.status = PlayerStatus{ - Title: title, - File: url, - Key: ratingKey, - NoReport: noReport, - TctMode: tctMode, - Time: float64(startOffset) / 1000.0, + _, 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} + } + } } - 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)) - 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) + if err != nil { + if ctx.Err() == context.Canceled { + return nil } + return err } - - slog.Debug("PlayerManager: playback initiated") - pm.reportProgressWithKey() pm.sendUpdate() - - return PlayerStatusMsg{} + return nil } } -func (pm *PlayerManager) Reconnect() tea.Cmd { - return func() tea.Msg { - if pm.conn != nil || !pm.socketExists() { - return nil +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() + if err := ctx.Err(); err != nil { + return previous, err + } + select { + case <-pm.done: + return previous, fmt.Errorf("music player is closed") + default: + } + 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{}) } - - c := mpvipc.NewConnection(pm.socketPath) - if err := c.Open(); err != nil { - os.Remove(pm.socketPath) - return nil + }() + audioOnly := output == config.AudioTerminal + _ = pm.conn.call("af", "remove", "@plexctl-spectrum") + if audioOnly { + _ = pm.conn.call("af", "add", spectrumFilter()) + _ = pm.ensureWaveformScript() + } + // 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 } - - pm.conn = c - if !pm.VerifyConnection() { - slog.Debug("PlayerManager: re-connected but IPC non-responsive, cleaning up") - pm.cleanup() - return nil + 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.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 req.Subtitles { + _ = pm.conn.call("sub-add", sub.URL, "auto", sub.Title, sub.Language) + } + 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 +} - pm.stopChan = make(chan struct{}) - pm.restoreStatusFromMpv() - go pm.monitorEvents() - - return 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) } -func (pm *PlayerManager) TogglePause() tea.Cmd { - return func() tea.Msg { - if !pm.VerifyConnection() { - return nil - } +type queueEntry struct { + ID int64 + PlayItem +} - 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 +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 } - pm.sendUpdate() } - return nil + 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 } -func (pm *PlayerManager) Stop() tea.Cmd { - return func() tea.Msg { - if pm.VerifyConnection() { - pm.conn.Call("quit") - pm.cleanup() +// connect is called with ioMu held. Connection identity prevents a stale +// monitor from clearing a replacement player. +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 { + if !isOverlayOutput(output) { + pm.attach(conn) + return nil + } + _ = conn.call("quit") } - return tea.Quit() + _ = conn.conn.Close() } -} - -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() + 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) + 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() { + 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) + 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 conn.get("mpv-version", nil) == nil { + pm.attach(conn) + return nil + } + _ = conn.conn.Close() } - 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 - } - return PlayerStatusMsgPoller{} +func (pm *PlayerManager) attach(conn *ipcClient) { + ctx, cancel := context.WithCancel(context.Background()) + pm.conn, pm.cancel = conn, cancel + pm.ensureReporter() + pm.workers.Add(1) + go func() { + defer pm.workers.Done() + pm.monitor(ctx, conn) + }() +} + +// 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) + } + } + }() }) } -func (pm *PlayerManager) sendUpdate() { - pm.updates <- PlayerStatusMsg{} +type reportBatch struct { + statuses []PlayerStatus + done chan struct{} } -func (pm *PlayerManager) WaitForUpdates() tea.Cmd { - return func() tea.Msg { - msg := <-pm.updates - return msg +func (pm *PlayerManager) enqueueReports(batch []PlayerStatus) { + if len(batch) == 0 { + return + } + select { + case pm.reports <- reportBatch{statuses: batch}: + case <-pm.reportCtx.Done(): } } -// Internal Helpers +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) ensureMpv() error { - _, err := exec.LookPath("mpv") - if err != nil { - return fmt.Errorf("mpv command not found. please install mpv: %w", err) +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(): } - return nil } -func (pm *PlayerManager) socketExists() bool { - if runtime.GOOS == "windows" { - _, err := os.OpenFile(pm.socketPath, os.O_RDWR, 0) +func (pm *PlayerManager) Reconnect() tea.Cmd { + return func() tea.Msg { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + select { + case <-pm.done: + return nil + default: + } + if pm.conn != nil { + return nil + } + conn, err := openIPC(pm.socketPath) if err != nil { - if pe, ok := err.(*os.PathError); ok { - if errno, ok := pe.Err.(syscall.Errno); ok { - if errno == 2 { - return false - } - } + return nil + } + if conn.get("mpv-version", nil) != nil { + _ = conn.conn.Close() + 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) + _ = 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. + } + 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 } - return true } - return true // Named pipes are harder to stat, let dialing handle it + pm.setStatus(status) + pm.attach(conn) + return nil } - _, err := os.Stat(pm.socketPath) - 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") +func (pm *PlayerManager) control(args ...any) tea.Cmd { + return func() tea.Msg { + pm.ioMu.Lock() + defer pm.ioMu.Unlock() + if pm.conn == nil { + return nil } - } else { - args = append(args, "--force-window=yes") + if err := pm.conn.call(args...); err != nil { + return fmt.Errorf("player control failed: %w", err) + } + return nil } +} - slog.Debug("PlayerManager: spawning mpv", "args", args) - cmd := exec.Command("mpv", args...) - if tctMode { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } +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") } - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start mpv: %w", err) - } - - // 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) StopPlayback() tea.Cmd { + ctx := pm.beginRequest(context.Background()) + return func() tea.Msg { return pm.stopCurrent(ctx) } } -func (pm *PlayerManager) setupObservers() { +func (pm *PlayerManager) stopCurrent(ctx context.Context) tea.Msg { + pm.ioMu.Lock() + select { + case <-pm.done: + pm.ioMu.Unlock() + return nil + default: + } + if ctx.Err() != nil { + pm.ioMu.Unlock() + return nil + } + previous := pm.Status() + boundaries := []PlayerStatus{} 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.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) monitorEvents() { - pm.setupObservers() - events := make(chan *mpvipc.Event) - stop := make(chan struct{}) +func (pm *PlayerManager) Stop() tea.Cmd { + return func() tea.Msg { pm.StopPlayback()(); return tea.Quit() } +} - go func() { - if pm.conn != nil { - pm.conn.ListenForEvents(events, stop) +// 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() { + // 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() } - }() - - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - defer func() { - rk := pm.status.Key - nr := pm.status.NoReport - pm.cleanup() - pm.sendUpdate() - if rk != "" && !nr { - pm.reportProgressWithKey() + pm.requestMu.Unlock() + pm.ioMu.Lock() + status := pm.Status() + 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) + } + }) +} + +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) { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + lastReport := time.Now() + lastRedraw := 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) + boundaries := pm.queueEvents(&status) + sample := status + if err == nil && !idle { + _ = conn.get("pause", &paused) + sample.State = operations.StatePlaying + if paused { + sample.State = operations.StatePaused + } + _ = 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) + sample.Spectrum = readSpectrum(metadata) + sample.Waveform = Waveform{} + _ = conn.get("user-data/plexctl-waveform", &sample.Waveform) + } + } else { + sample.State = operations.StateStopped + sample.Spectrum, sample.Waveform = Spectrum{}, Waveform{} } - if ev.Name == "property-change" { - slog.Log(context.Background(), config.LevelTrace, "PlayerManager: property change", "id", ev.ID, "data", ev.Data) - pm.handlePropertyChange(ev) + // 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 } - case <-ticker.C: - if pm.status.Active() && pm.status.Key != "" { - slog.Log(context.Background(), config.LevelTrace, "PlayerManager: periodic progress report") - pm.reportProgressWithKey() + boundaries = append(boundaries, later...) + if previous.Key != status.Key || previous.File != status.File { + pm.persistTrack(status) } - } - } -} - -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 + 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() + } + changed := previous.State != status.State || previous.Key != status.Key + if changed { + pm.sendUpdate() + } + 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 pm.reports <- reportBatch{statuses: []PlayerStatus{status}}: + default: + } + lastReport = time.Now() + } + pm.ioMu.Unlock() + if err != nil || ctx.Err() != nil { + 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() - if pm.status.NoReport || pm.status.Key == "" { +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) - - client, err := plex.NewClient() + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + ctx = config.MediaServer(status.Server).Context(ctx) + client, err := plex.NewClientForContext(ctx) if err != nil { return } - - st := operations.State(pm.status.State) - forNow := "PlexCTL" - req := operations.ReportRequest{ - State: &st, - Time: &timePos, - Duration: &duration, - Key: &pm.status.Key, - RatingKey: &pm.status.Key, - Device: &forNow, - } - _, _ = client.SDK.Timeline.Report(context.Background(), 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/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..a6a2021 --- /dev/null +++ b/internal/tui/player/manager_test.go @@ -0,0 +1,76 @@ +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} + 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..52fd262 --- /dev/null +++ b/internal/tui/player/overlay.go @@ -0,0 +1,91 @@ +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 + } + 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 new file mode 100644 index 0000000..7f16f60 --- /dev/null +++ b/internal/tui/player/overlay_integration_test.go @@ -0,0 +1,198 @@ +//go:build integration && !windows + +package player + +import ( + "context" + "math" + "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) { + layout.Update(78, 24, false) + config.Get().SixelVideoWidth = 160 + 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() }) + 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() + 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 172da66..e017223 100644 --- a/internal/tui/player/player_state.go +++ b/internal/tui/player/player_state.go @@ -1,16 +1,32 @@ package player -import "github.com/LukeHagar/plexgo/models/operations" +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 { - Time float64 - Duration float64 - Title string - File string - Key string - State operations.State - NoReport bool - TctMode bool + Server config.ServerTarget + Time float64 + Duration float64 + Title string + File string + Key string + State operations.State + NoReport bool + AudioOnly bool + Overlay bool + Volume float64 + Muted bool + Spectrum Spectrum + Waveform Waveform } type ExternalSubtitle struct { @@ -24,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/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/terminal.go b/internal/tui/player/terminal.go new file mode 100644 index 0000000..e01d83a --- /dev/null +++ b/internal/tui/player/terminal.go @@ -0,0 +1,783 @@ +package player + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "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) + 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) 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(ctx, preference, os.Getenv) + if err != nil { + return err + } + playback := &terminalPlayback{ + 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: 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.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 { + 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{ + // 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", + "--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 { + // 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") + } + 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() (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() + 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(ctx, 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 { + request := p.request + if request == nil { + request = context.Background() + } + 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") + } + } + 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(startup, 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 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) + } + + 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 + 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() + if p.output == config.VideoSixel { + graphics = &termgraphics.Writer{Output: p.stdout} + cmd.Stdout = graphics + defer func() { + _, _ = 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 { + cmd.Args = append(cmd.Args, p.sixelArgs()...) + } + updateOrigin := func() (bool, error) { + if paneZoom == nil || graphics == nil { + return false, nil + } + 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. + 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 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") }() + } + } + p.attempted = true + 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(startup, socket, done) + if err != nil { + return err + } + events := make(chan terminalEvent, 64) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + readTerminalEvents(ctx, conn, events) + }() + defer func() { + cancel() + if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + slog.Debug("Close terminal player connection", "error", err) + } + <-readerDone + }() + + properties := []string{"time-pos", "pause", "duration"} + 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 + } + } + // 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(startup, events, &pending); err != nil { + return err + } + } + if err := sendTerminalCommand(conn, 3, "get_property", "playlist"); 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() + 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() + pendingKeys := "" + forwardKeys := func(flush bool) error { + 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 if input.Key == "f" && paneZoom != nil { + command = []any{"cycle", "fullscreen"} + } else { + key := input.Key + if input.Mouse != nil { + key = input.Mouse.key() + } + if key != "" { + command = []any{"keypress", key} + } + } + 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 + } + } + if pendingKeys != "" { + keyTimer.Reset(50 * time.Millisecond) + } + 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": + // The startup deadline must not limit the running video's lifetime. + startupDone = nil + stopStartup() + 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": + 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" { + 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 <-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 + 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 <-controlTicker.C: + if !loaded { + continue + } + 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 + } + } + 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 { + if err := resizeVideoOutput(p.output, p.sixelArgs(), videoGraph, func(args ...any) error { + return sendTerminalCommand(conn, 0, args...) + }); 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. + 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. + select { + case <-done: + waited = true + return p.exitError(waitErr) + case <-time.After(2 * time.Second): + return fmt.Errorf("terminal player disconnected from IPC") + } + } + // 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 err := handleEvent(ev); err != nil { + return err + } + if p.playlist != nil && len(pending) > 0 { + for _, buffered := range pending { + if err := handleEvent(buffered); err != nil { + return err + } + } + pending = nil + } + case <-ticker.C: + if loaded { + p.reportStatus() + } + } + } +} + +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(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") + } + 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). + 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 { + 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) { + 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) + case 4: + _ = json.Unmarshal(ev.Data, &p.status.Volume) + case 5: + _ = json.Unmarshal(ev.Data, &p.status.Muted) + } +} + +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: + 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 { + 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) + } + 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 +} + +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_controls.go b/internal/tui/player/terminal_controls.go new file mode 100644 index 0000000..13deac6 --- /dev/null +++ b/internal/tui/player/terminal_controls.go @@ -0,0 +1,281 @@ +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 + canFullscreen bool + fullscreen 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)) + 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}} + 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) + 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 +} + +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.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" + } + 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 "f": + if c.canFullscreen { + return []any{"cycle", "fullscreen"} + } + 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..92f6d96 --- /dev/null +++ b/internal/tui/player/terminal_controls_test.go @@ -0,0 +1,138 @@ +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, 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 { + 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}, {"cycle", "fullscreen"}, {"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 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} + 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 new file mode 100644 index 0000000..8e8787e --- /dev/null +++ b/internal/tui/player/terminal_integration_test.go @@ -0,0 +1,331 @@ +//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 + 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}, + {name: "controls-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) + }, + } + 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) + } + 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 + } + 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 := 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()) + 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" || scenario == "auto-startup-failure" && slices.Contains(os.Args, "--vo=kitty") { + os.Exit(3) + } + 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") { + 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) + } + t.Cleanup(func() { + if err := listener.Close(); err != nil { + t.Error(err) + } + }) + conn, err := listener.Accept() + if err != nil { + os.Exit(13) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + }) + decoder := json.NewDecoder(conn) + encoder := json.NewEncoder(conn) + loaded, playlist, unpaused, subtitle := false, false, false, scenario == "tui" + for !loaded || !playlist || !unpaused || !subtitle { + var request struct { + Command json.RawMessage `json:"command"` + RequestID int `json:"request_id"` + } + if decoder.Decode(&request) != nil { + os.Exit(14) + } + 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 + } + var command []any + if json.Unmarshal(request.Command, &command) != nil || len(command) == 0 { + os.Exit(14) + } + 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 + } + } + + 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 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) + 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..1708715 --- /dev/null +++ b/internal/tui/player/terminal_keys.go @@ -0,0 +1,197 @@ +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", + "\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 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 inputs, input + } + return inputs, "" // Drop malformed reports without sending their digits to mpv. + } + end += 3 + 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, name := range escapeKeys { + if strings.HasPrefix(input, sequence) { + key(name) + input = input[len(sequence):] + matched = true + break + } + partial = partial || strings.HasPrefix(sequence, input) + } + if matched { + continue + } + if partial && !flush { + 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 inputs, input + } + r, size := utf8.DecodeRuneInString(input) + input = input[size:] + switch r { + case ' ': + key("SPACE") + case '\r', '\n': + key("ENTER") + case '\t': + key("TAB") + case '\x1b': + key("ESC") + case '\x03': + key("q") + case '\x7f', '\b': + key("BS") + default: + if r >= 32 && r != utf8.RuneError { + key(string(r)) + } + } + } + return inputs, "" +} + +func parsePlaybackMouse(report string, pressed bool) *playbackMouse { + fields := strings.Split(report, ";") + 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 >= 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: + 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..8969aca --- /dev/null +++ b/internal/tui/player/terminal_keys_test.go @@ -0,0 +1,83 @@ +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[" { + 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_queue_integration_test.go b/internal/tui/player/terminal_queue_integration_test.go new file mode 100644 index 0000000..1071637 --- /dev/null +++ b/internal/tui/player/terminal_queue_integration_test.go @@ -0,0 +1,227 @@ +//go:build integration && !windows + +package player + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "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 { + 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/terminal_unix.go b/internal/tui/player/terminal_unix.go new file mode 100644 index 0000000..1860043 --- /dev/null +++ b/internal/tui/player/terminal_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +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 new file mode 100644 index 0000000..c9efbaa --- /dev/null +++ b/internal/tui/player/terminal_windows.go @@ -0,0 +1,16 @@ +package player + +import ( + "net" + "os" + "time" + + "gopkg.in/natefinch/npipe.v2" +) + +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/player/video_output.go b/internal/tui/player/video_output.go new file mode 100644 index 0000000..474654c --- /dev/null +++ b/internal/tui/player/video_output.go @@ -0,0 +1,128 @@ +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() + 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) + } + 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") +} + +// 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 cellW <= 0 || cellH <= 0 { + cellW, cellH = 8, 16 + } + columns, rows = max(1, columns), max(2, rows) + width := columns * cellW + if limit > 0 { + width = min(width, 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), + } +} + +// 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/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/player/video_output_test.go b/internal/tui/player/video_output_test.go new file mode 100644 index 0000000..b528859 --- /dev/null +++ b/internal/tui/player/video_output_test.go @@ -0,0 +1,72 @@ +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" + 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/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..e81f79a --- /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 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) + } +} 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 new file mode 100644 index 0000000..d54b06b --- /dev/null +++ b/internal/tui/player/window_load.go @@ -0,0 +1,63 @@ +package player + +import ( + "encoding/json" + "fmt" + "log/slog" + "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 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 + } + request := map[string]any{ + "request_id": 1, + "command": mediaLoadCommand(url, startOffset, false), + } + 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 + } +} + +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" + options["vo"] = "null" + } + return map[string]any{"name": "loadfile", "url": url, "flags": "replace", "options": options} +} diff --git a/internal/tui/player/window_load_test.go b/internal/tui/player/window_load_test.go new file mode 100644 index 0000000..d35521a --- /dev/null +++ b/internal/tui/player/window_load_test.go @@ -0,0 +1,79 @@ +//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) + } + 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) + } + 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 func() { + if err := conn.Close(); err != nil { + done <- err + } + }() + 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) + 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) + } + for err := range done { + if err != nil { + t.Error(err) + } + } + }) + } +} 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/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 890f6a8..9e0fa5e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -14,15 +14,20 @@ 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/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" ) @@ -36,12 +41,35 @@ type Controller struct { player *player.PlayerManager alert bubbleup.AlertModel - playerStatus player.PlayerStatus - returnTabIdx int + playerStatus player.PlayerStatus + returnTabIdx int + visualizer visualizer.Model + 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() @@ -100,7 +128,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()) @@ -116,15 +143,63 @@ 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 { + _, cmd := status.Update(msg) + cmds = append(cmds, cmd) + } + } + return c, tea.Batch(cmds...) + 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) + cmds = append(cmds, cmd) + } + } + return c, tea.Batch(cmds...) + case ui.MediaPageMsg: + // Request IDs and server IDs keep asynchronous replies with their view. + for _, model := range c.tabManager.tabModels { + 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, 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 { 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) } } 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 { @@ -133,7 +208,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(), tea.ClearScreen) case tuiconfig.ConfigFinishedMsg: cfg := config.Get() @@ -156,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 { @@ -171,81 +257,121 @@ 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) } - 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() { + c.focusPane(paneContent) + return c, target.JumpToLetter(msg.Letter) + } + return c, nil case ui.RequestPlayMsg: - return c, player.FetchAndPlay(msg.RatingKey, msg.TctMode) + return c, player.FetchAndPlay(msg.RatingKey, msg.Output, msg.Server) 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, msg.Server)) case ui.SelectMediaMsg: return c, c.handleSelectMedia(msg) 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 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()) + 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() { + return c, c.navigator.Push(letterjump.New(c.theme)) + } case "q", "ctrl+c": - if config.Get().CloseVideoOnQuit { + if config.Get().CloseVideoOnQuit || c.playerStatus.AudioOnly || c.playerStatus.Overlay { return c, c.player.Stop() } 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": + case "H": + c.focusPane(paneContent) 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 config.IsMusic(meta.Type) { + if msg.String() == "v" { + return c, c.navigator.Push(playback.NewMusic(*meta.RatingKey, meta.Title, c.theme, c.mediaServer())) + } + 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, c.mediaServer())) + } + server := c.mediaServer() return c, func() tea.Msg { - return ui.RequestPlayMsg{ + return ui.RequestPlayMsg{Server: server, RatingKey: *meta.RatingKey, - TctMode: tctMode, + Output: output, } } } @@ -257,18 +383,31 @@ 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() { 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)) + 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": + c.focusPane(paneContent) return c, c.tabManager.PrevTab() } + if c.focus != paneContent { + return c, nil + } } var alertCmd tea.Cmd @@ -294,10 +433,14 @@ 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 != "" { + 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, @@ -307,7 +450,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 } @@ -320,7 +463,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, @@ -332,6 +475,16 @@ 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 + 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 @@ -340,6 +493,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)) @@ -351,6 +505,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) } @@ -359,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) @@ -379,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) @@ -417,50 +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.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") - } - if len(res.Object.Users) <= 1 { - slog.Debug("TUI: 0 or 1 home user, skipping switch") - return nil + return usersLoadedMsg{id: id, err: fmt.Errorf("no home data returned")} } - 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 { - updates := make(chan interface{}, 10) - go plex.LoadData(context.Background(), updates) + ctx, cancel := context.WithTimeout(server.Context(context.Background()), 2*time.Minute) + defer cancel() + updates := make(chan any, 10) + 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} } } } @@ -468,12 +639,26 @@ 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)"}, {Key: "tab", Desc: "Switch Library"}, - {Key: "h", Desc: "Home"}, + {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"}, {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: waveform / spectrum / mirror / off"}, {Key: "x", Desc: "Stop Playback"}, {Key: "q", Desc: "Quit"}, {Key: "?", Desc: "Help"}, @@ -491,9 +676,10 @@ 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 c.alert.Render(base) + return artwork.Frame(c.alert.Render(base)) } func (c *Controller) renderBaseView() string { @@ -506,24 +692,27 @@ 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 // ContentHeight already accounts for player bar if active contentHeight := layout.ContentHeight() - windowStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder(), true). - BorderForeground(c.theme.BrightBlack()). - Padding(0, 1). - Width(layout.MainAreaContentWidth()). - Height(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)). + Padding(0, 1). + Width(layout.MainAreaContentWidth() + 2). + Height(contentHeight) + + content = lipgloss.NewStyle().MaxWidth(layout.InnerWidth()).MaxHeight(contentHeight).Render(content) body := windowStyle.Render(content) if c.playerStatus.Active() { @@ -538,13 +727,93 @@ 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 := "Ctrl+HJKL: panes | [p] Play | [v] Player | [V] View | [/] Search | [?] Help | [q] Quit" + if c.playerStatus.Active() { + 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" + 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)) +} + +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 { + 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) + } + 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() && 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() + 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() + if c.playerStatus.AudioOnly { + return c.renderMusicPlayer() + } width := layout.MainAreaContentWidth() status := "▶ PLAYING" if c.playerStatus.Paused() { @@ -553,7 +822,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) @@ -568,7 +837,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), @@ -578,3 +848,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/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..ae2c52b --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,226 @@ +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" + "github.com/ygelfand/plexctl/internal/ui" + "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 + 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 + 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") + } + }) + } +} diff --git a/internal/tui/view/detail/base.go b/internal/tui/view/detail/base.go index 64c2062..897e5fc 100644 --- a/internal/tui/view/detail/base.go +++ b/internal/tui/view/detail/base.go @@ -1,29 +1,33 @@ 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 { - RatingKey string - Metadata *components.Metadata - Poster string - Width int - Height int - Theme tint.Tint - Err error - Loading bool + Server config.ServerTarget + RatingKey string + Metadata *components.Metadata + Poster string + posterWidth int + Width int + Height int + Theme tint.Tint + Err error + Loading bool 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, @@ -42,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: @@ -49,17 +56,22 @@ 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)) - case posterDataMsg: - b.Poster = string(msg) + cmds = append(cmds, fetchPoster(b.Metadata, b.Width, b.Server)) + 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 @@ -75,7 +87,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 @@ -101,12 +113,15 @@ 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) } 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 +134,26 @@ 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 +} + +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 718025e..953c05c 100644 --- a/internal/tui/view/detail/detail_shared.go +++ b/internal/tui/view/detail/detail_shared.go @@ -1,35 +1,41 @@ 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" 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/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 string +type PosterDataMsg struct { + serverID string + ratingKey string + width int + view string +} type BackToLibraryMsg struct{} @@ -51,9 +57,27 @@ func (i metadataItem) FilterValue() string { return i.metadata.Title } // Shared Helpers -func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { +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, servers ...config.ServerTarget) tea.Cmd { + server := config.MediaServer(servers...) + 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 +86,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 := plex.GetCachedPoster(rk, targetWidth); ok { - return posterDataMsg(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() - data, err := plex.GetImage(context.Background(), *metadata.Thumb) + ctx, cancel := context.WithTimeout(server.Context(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 @@ -82,25 +106,20 @@ func fetchPoster(metadata *components.Metadata, width int) tea.Cmd { 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 } - imgStr, err := gopixels.FromImageStream(img, targetWidth, 0, "halfcell", true) + 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)) - // Save to long-term cache - if rk != "" { - plex.SetCachedPoster(rk, targetWidth, imgStr) - } - - return posterDataMsg(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 84d658b..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", } @@ -92,8 +97,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"}, } } @@ -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" } @@ -141,5 +149,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/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 c774280..ca7c352 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" @@ -18,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...), } } @@ -31,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} } } @@ -45,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) { @@ -75,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, false) + return player.FetchAndPlayTrailer(context.Background(), v.Metadata, config.VideoWindow, v.Server) } func (v *MovieDetailView) hasTrailer() bool { @@ -98,8 +103,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"}) @@ -111,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" } @@ -193,7 +201,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..82b6af2 --- /dev/null +++ b/internal/tui/view/detail/music_detail.go @@ -0,0 +1,358 @@ +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/config" + "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/widget/poster" + "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 + albums *poster.PosterGrid + albumsRect ui.Rect + rail ui.AlphabetRail + jumpNote string +} + +func NewMusicDetailView(ratingKey string, theme tint.Tint, servers ...config.ServerTarget) *MusicDetailView { + items := createBaseDetailList() + items.Paginator.Type = paginator.Arabic + return &MusicDetailView{DetailBase: NewDetailBase(ratingKey, theme, servers...), items: items, + albums: poster.NewPosterGrid(nil, theme, servers...)} +} + +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(v.Server.Context(context.Background()), 30*time.Second) + defer cancel() + meta, err := plex.GetMetadata(ctx, v.RatingKey, true) + if err != nil { + return v.dataError(err) + } + var children []components.Metadata + if meta.Type == "artist" || meta.Type == "album" { + children, err = plex.GetMusicChildren(ctx, meta) + if err != nil { + return v.dataError(err) + } + } + return detailDataMsg{serverID: v.Server.CacheNamespace(), ratingKey: v.RatingKey, metadata: meta, children: children} +} + +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 + } + return v.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) 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) + } + 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, tea.Batch(v.albums.Init(), fetchPoster(v.Metadata, v.Width, v.Server)) + } + _, 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")) + } + return v, nil + } + } + 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 + } + 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}}) + } + v.items.SetItems(items) + 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, v.Server) + 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 meta := v.GetSelectedMetadata(); meta != nil && meta.RatingKey != nil && meta.Type != "artist" { + v.selected = NewMusicDetailView(*meta.RatingKey, v.Theme, v.Server) + 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) +} + +func (v *MusicDetailView) HelpKeys() []ui.HelpKey { + return []ui.HelpKey{ + {Key: "enter", Desc: "Open Album / Track Details"}, + {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"}, + {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() + 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{} + v.rail.Rect = ui.Rect{} + footer := "[p] Play | [v] Player | [esc] Back" + switch v.Metadata.Type { + case "track": + header += "\n\n" + renderBadges(v.Metadata, v.Theme) + header = v.RenderPosterAndInfo(header) + 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.SetBounds(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" + 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.SetBounds(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.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) + 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) +} + +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..320ae9d --- /dev/null +++ b/internal/tui/view/detail/music_detail_test.go @@ -0,0 +1,224 @@ +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/tui/widget/poster" + "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") + } +} + +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 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" { + 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") + } +} + +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/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..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" ) @@ -17,11 +18,12 @@ type SeasonDetailView struct { children []components.Metadata episodeList list.Model selectedEpisode *EpisodeDetailView + 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(), } } @@ -36,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} } } @@ -58,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) { @@ -79,7 +87,19 @@ 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 { + if ui.ListMouse(&v.episodeList, local) { + return v.Update(ui.KeyPress("enter")) + } + return v, nil + } + } switch msg := msg.(type) { case detailDataMsg: @@ -94,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": @@ -102,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", } @@ -114,7 +134,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...) } @@ -131,8 +153,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"}, } } @@ -143,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" } @@ -167,6 +193,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 +204,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..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" ) @@ -19,11 +20,12 @@ type ShowDetailView struct { children []components.Metadata seasonList list.Model selectedSeason *SeasonDetailView + 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(), } } @@ -38,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} } } @@ -60,28 +65,43 @@ 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) { 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 } + 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 { + if ui.ListMouse(&v.seasonList, local) { + return v.Update(ui.KeyPress("enter")) + } + return v, nil + } + } switch msg := msg.(type) { case detailDataMsg: @@ -96,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": @@ -111,8 +131,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...) } @@ -127,8 +145,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"}, } } @@ -139,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" } @@ -198,6 +220,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/friends.go b/internal/tui/view/friends.go new file mode 100644 index 0000000..24596e9 --- /dev/null +++ b/internal/tui/view/friends.go @@ -0,0 +1,276 @@ +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 + server := config.Get().ServerTarget() + return func() tea.Msg { + ctx, cancel := context.WithTimeout(server.Context(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..1b54cbe --- /dev/null +++ b/internal/tui/view/friends_test.go @@ -0,0 +1,111 @@ +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) { + 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 + } + 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/history.go b/internal/tui/view/history.go index 530946e..e02985d 100644 --- a/internal/tui/view/history.go +++ b/internal/tui/view/history.go @@ -16,13 +16,16 @@ import ( ) type HistoryTab struct { - table table.Model + table ui.Table width int height int metadata []operations.ListPlaybackHistoryMetadata dayCursor time.Time isLoading bool + hasMore bool + request viewRequest + err error } func NewHistoryTab(theme tint.Tint) *HistoryTab { @@ -38,43 +41,59 @@ 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) { 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 + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) 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() { @@ -89,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, @@ -104,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 @@ -115,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.Msg { - store, err := plex.GetStore() +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 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 i := 0; i < 30; i++ { - gte := t.dayCursor.Add(-24 * time.Hour) - lte := t.dayCursor + for range 30 { + 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) @@ -175,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). @@ -190,7 +223,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..292b5f5 --- /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(DataLoadedMsg{owner: tab, data: 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/home.go b/internal/tui/view/home.go index 1e14417..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,16 +30,34 @@ 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() +} + +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) + } + return nil +} func (v *HomeView) Init() tea.Cmd { - return v.fetchHubs + return v.Refresh() } func (v *HomeView) Refresh() tea.Cmd { @@ -46,19 +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 v.details.Active() { + 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) + } + } + _, homeResult := msg.(DataLoadedMsg) + if v.details.Active() && !homeResult { cmd, handled := v.details.Update(msg) if handled { return v, cmd @@ -69,6 +94,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() @@ -80,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 { @@ -105,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()) } @@ -137,11 +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, v.request.server) default: - dv = detail.NewMovieDetailView(ratingKey, v.theme) + dv = detail.NewMovieDetailView(ratingKey, v.theme, v.request.server) } layout := ui.GetLayout() @@ -154,21 +208,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": @@ -190,8 +234,31 @@ 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 := 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) } @@ -231,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." } @@ -240,6 +310,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 { @@ -247,8 +324,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/view/libraries.go b/internal/tui/view/libraries.go index a045a6e..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 table.Model - 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,21 +85,30 @@ func (t *LibrariesTab) fetchLibraries() tea.Msg { }) } - return rows + return rows, nil } 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 + t.width = ui.GetLayout().InnerWidth() t.height = msg.Height t.table.SetHeight(ui.GetTableHeight(t.height)) 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": @@ -118,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 new file mode 100644 index 0000000..98d15e1 --- /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(DataLoadedMsg{owner: tab, data: navigationRows(len(tab.table.Columns()))}) + verifyTableNavigation(t, tab, &tab.table) +} diff --git a/internal/tui/view/media.go b/internal/tui/view/media.go index 50a83ba..2c2220c 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" @@ -28,22 +31,33 @@ const ( ) type MediaView struct { + server config.ServerTarget sectionID string title string - table table.Model + table ui.Table posterGrid *poster.PosterGrid theme tint.Tint err error 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 } -func NewMediaView(sectionID, title string, theme tint.Tint) *MediaView { +var pageRequestID atomic.Uint64 + +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}, @@ -64,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 } @@ -76,13 +90,16 @@ 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{}, + totalItems: -1, + hasMore: true, } } @@ -102,31 +119,45 @@ 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) - client, err := plex.NewClient() + 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.NewClientForContext(ctx) if err != nil { - return err + page.Err = err + return page } cfg := config.Get() - serverID, _, _ := cfg.GetActiveServer() 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) + 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 } @@ -136,87 +167,126 @@ 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 _, ok := msg.(detail.DataMsg); ok && !v.details.Active() { + return v, nil + } + 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 { + 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.ServerID != "" && msg.ServerID != v.server.ID) || 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) 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 "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) } - if ok { + if ok && id == v.server.ID { if server.Libraries.Settings == nil { server.Libraries.Settings = make(map[string]config.LibraryOptions) } @@ -245,6 +315,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() } } @@ -257,20 +332,103 @@ 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) 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 { + 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.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 + v.table.SetHeight(rect.Height) + v.table.SetWidth(rect.Width) + v.posterGrid.SetSize(rect.Width, rect.Height) + } } func (v *MediaView) GetSectionID() string { @@ -300,13 +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, v.server) default: - dv = detail.NewMovieDetailView(ratingKey, v.theme) + dv = detail.NewMovieDetailView(ratingKey, v.theme, v.server) } layout := ui.GetLayout() @@ -323,9 +483,9 @@ func (v *MediaView) HelpKeys() []ui.HelpKey { } return []ui.HelpKey{ {Key: "enter", Desc: "View Details"}, - {Key: "v", Desc: "Toggle View"}, - {Key: "j/up", Desc: "Move Up"}, - {Key: "k/down", Desc: "Move Down"}, + {Key: "V", Desc: "Toggle List / Poster View"}, + {Key: "k/up", Desc: "Move Up"}, + {Key: "j/down", Desc: "Move Down"}, } } @@ -333,12 +493,54 @@ 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() } + 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 v.server.Shared { + footer = "Shared · " + v.title + " · " + footer + } + if status != "" { + footer += " · " + status + } + return body + "\n" + lipgloss.NewStyle().Foreground(v.theme.BrightBlack()). + Render(ui.Ellipsis(footer, ui.GetLayout().InnerWidth())) +} - return v.posterGrid.View() +func (v *MediaView) ActionRows() int { + return 1 } func (v *MediaView) syncTableRows() { @@ -349,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/view/media_test.go b/internal/tui/view/media_test.go new file mode 100644 index 0000000..4d28e9c --- /dev/null +++ b/internal/tui/view/media_test.go @@ -0,0 +1,187 @@ +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") + } +} + +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/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 b2c7ecb..db67d5f 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, @@ -41,39 +45,71 @@ 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 + index := search.GetIndex() + return tea.Batch(v.spinner.Tick, func() tea.Msg { + defer close(progress) + err := index.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) { + 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)) - 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 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 { @@ -83,32 +119,45 @@ 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} + var cmd tea.Cmd + v.viewport, cmd = v.viewport.Update(msg) + return v, cmd } 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 { + 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() titleStyle := ui.TitleStyle(v.theme) labelStyle := ui.LabelStyle(v.theme) @@ -119,17 +168,18 @@ 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")) 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, "") @@ -150,11 +200,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 b5fce26..d043af3 100644 --- a/internal/tui/view/sessions.go +++ b/internal/tui/view/sessions.go @@ -3,7 +3,9 @@ package view import ( "context" "encoding/json" + "fmt" "io" + "log/slog" "github.com/LukeHagar/plexgo/models/operations" "github.com/charmbracelet/bubbles/table" @@ -15,9 +17,11 @@ import ( ) type SessionsTab struct { - table table.Model - width int - height int + table ui.Table + width int + height int + request viewRequest + err error } func NewSessionsTab(theme tint.Tint) *SessionsTab { @@ -30,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 { @@ -62,26 +68,30 @@ 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 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 + return nil, err } var sRes sessionsResponse if err := json.Unmarshal(body, &sRes); err != nil { - return err + return nil, err } var rows []table.Row @@ -95,25 +105,27 @@ 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) { 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) 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": @@ -132,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). @@ -164,7 +179,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..0e38148 --- /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(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 19149c1..b033934 100644 --- a/internal/tui/view/tasks.go +++ b/internal/tui/view/tasks.go @@ -14,9 +14,11 @@ import ( ) type TasksTab struct { - table table.Model - 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,21 +89,27 @@ func (t *TasksTab) fetchTasks() tea.Msg { }) } - return rows + return rows, nil } 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) 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). @@ -167,7 +180,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..0e43070 --- /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(DataLoadedMsg{owner: tab, data: navigationRows(len(tab.table.Columns()))}) + verifyTableNavigation(t, tab, &tab.table) +} 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..2c8972c --- /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 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 + } + } + 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..f648466 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, @@ -278,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/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/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/navbar/navbar.go b/internal/tui/widget/navbar/navbar.go index 0a88a72..23dee83 100644 --- a/internal/tui/widget/navbar/navbar.go +++ b/internal/tui/widget/navbar/navbar.go @@ -26,12 +26,19 @@ type NavSection struct { type Navbar struct { sections []NavSection activeFlatIdx int + cursor int + focused bool theme tint.Tint Width int Height int 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 +66,43 @@ 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 +} + +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 +123,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) @@ -110,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()). @@ -123,8 +169,12 @@ func (n *Navbar) View() string { } for _, item := range section.Items { - content := n.formatItem(item) - if flatIdx == n.activeFlatIdx { + 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 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)) @@ -133,14 +183,46 @@ 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", " [L] Libraries", " [u] Switch user"} + if n.Height < 12 { + n.actionRows = nil + } + n.actionTop = max(1, n.Height-len(n.actionRows)) + 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 { + 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") + 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/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..c0df82b --- /dev/null +++ b/internal/tui/widget/playback/playback.go @@ -0,0 +1,137 @@ +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" +) + +type choice struct { + label string + output config.VideoOutput +} + +var choices = []choice{ + {"Automatic", config.VideoAuto}, + {"Kitty graphics", config.VideoKitty}, + {"Sixel graphics", config.VideoSixel}, + {"Text (compatible terminals)", config.VideoTCT}, +} + +type Model struct { + server config.ServerTarget + ratingKey string + title string + theme tint.Tint + choice int + width int + choices []choice + music bool +} + +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 + } + } + return m +} + +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 { + m.choice = 1 + } + 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(m.choices)-1, m.choice+delta)) + } else if ui.LeftClick(msg) { + for i, choice := range m.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(m.choices) - 1) % len(m.choices) + case "down", "j": + m.choice = (m.choice + 1) % len(m.choices) + case "esc", "q": + return nil, nil + case "enter": + output := m.choices[m.choice].output + return nil, func() tea.Msg { + return ui.RequestPlayMsg{Server: m.server, 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), ""} + 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) + } + 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), "", + "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)] + 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 2ee4b04..b209b2c 100644 --- a/internal/tui/widget/poster/grid.go +++ b/internal/tui/widget/poster/grid.go @@ -1,23 +1,28 @@ package poster import ( + "strings" + "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/plex" + "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} @@ -27,7 +32,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(server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { items[i].Poster = cached items[i].Loading = false } @@ -35,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), @@ -45,6 +51,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 { @@ -57,7 +93,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(m.server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -65,32 +101,30 @@ 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() } 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 } 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 { - cmds = append(cmds, FetchPoster(0, i, m.Items[i].Metadata)) + if m.Items[i].Loading && !m.Items[i].Fetching { + m.Items[i].Fetching = true + cmds = append(cmds, FetchPoster(0, i, m.Items[i].Metadata, m.server)) } } return tea.Batch(cmds...) @@ -104,17 +138,52 @@ 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: + 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 + } 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 { + 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 := m.columns() + 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() + 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 @@ -137,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} } } } @@ -148,8 +218,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 @@ -159,7 +233,6 @@ func (m *PosterGrid) syncScroll() tea.Cmd { m.viewport.SetYOffset(targetY - m.viewport.Height + ui.PosterTotalHeight) } - return m.fetchVisible() } func (m *PosterGrid) View() string { @@ -167,19 +240,24 @@ func (m *PosterGrid) View() string { return "" } - layout := ui.GetLayout() - cols := layout.PosterColumns() - - var rows []string - var currentRow []string + cols := m.columns() - 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 5046a5c..fca5ea3 100644 --- a/internal/tui/widget/poster/item.go +++ b/internal/tui/widget/poster/item.go @@ -1,21 +1,18 @@ package poster import ( - "bytes" "context" "fmt" - "image" "strings" - - _ "image/jpeg" - _ "image/png" + "time" "github.com/LukeHagar/plexgo/models/components" 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/config" "github.com/ygelfand/plexctl/internal/plex" + "github.com/ygelfand/plexctl/internal/tui/artwork" "github.com/ygelfand/plexctl/internal/ui" ) @@ -23,12 +20,15 @@ type PosterItem struct { Metadata components.Metadata Poster string Loading bool + Fetching bool } type PosterLoadedMsg struct { - ListID int - Index int - View string + ServerID string + ListID int + Index int + View string + RatingKey string } type ItemSelectedMsg struct { @@ -36,16 +36,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{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk} if rk != "" { - if cached, ok := plex.GetCachedPoster(rk, ui.PosterWidth); ok { - return PosterLoadedMsg{ListID: listID, Index: index, View: cached} + 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} } } @@ -57,31 +59,35 @@ 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(server.Context(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)) + img, err := artwork.Decode(data) if err != nil { - return nil + return result } - imgStr, err := gopixels.FromImageStream(img, ui.PosterWidth, 0, "halfcell", true) + imgStr, err := artwork.Render(server.CacheNamespace()+"/"+rk, img, ui.PosterWidth, ui.PosterHeight) if err != nil { - return nil + return result } - if rk != "" { - plex.SetCachedPoster(rk, ui.PosterWidth, imgStr) - } - - return PosterLoadedMsg{ListID: listID, Index: index, View: imgStr} + return PosterLoadedMsg{ServerID: server.CacheNamespace(), ListID: listID, Index: index, RatingKey: rk, View: imgStr} } } @@ -89,10 +95,7 @@ func FetchPoster(listID int, index int, metadata components.Metadata) tea.Cmd { 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 } @@ -112,6 +115,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) @@ -124,7 +128,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) } @@ -166,16 +172,13 @@ 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 { 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/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 4164fc8..168a415 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/plex" + "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 := plex.GetCachedPoster(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 := plex.GetCachedPoster(rk, ui.PosterWidth); ok { + if cached, ok := artwork.Cached(m.server.CacheNamespace()+"/"+rk, ui.PosterWidth); ok { newItems[i].Poster = cached newItems[i].Loading = false } @@ -86,8 +90,9 @@ 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 { - cmds = append(cmds, FetchPoster(m.ID, i, m.Items[i].Metadata)) + 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, m.server)) } } return tea.Batch(cmds...) @@ -96,13 +101,20 @@ 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 + } 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: - return m, m.fetchVisible() + return m, m.fixScroll() } if !m.Active { @@ -110,12 +122,30 @@ 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": 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": @@ -153,10 +183,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/poster/list_test.go b/internal/tui/widget/poster/list_test.go new file mode 100644 index 0000000..283962c --- /dev/null +++ b/internal/tui/widget/poster/list_test.go @@ -0,0 +1,43 @@ +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) + } +} + +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") + } + } +} 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..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" @@ -8,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/tui/player" "github.com/ygelfand/plexctl/internal/ui" ) @@ -15,22 +17,24 @@ import ( type ResumeDecisionMsg struct { Resume bool Metadata *components.Metadata - TctMode bool + Output config.VideoOutput } type ResumeOverlayModel struct { + server config.ServerTarget 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 { - 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, - TctMode: tctMode, + Output: output, theme: theme, choice: 0, } @@ -40,8 +44,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 +69,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(context.Background(), m.Metadata, false, m.Output, offset, m.server) case "esc": return nil, nil } @@ -66,7 +84,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 +95,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 +126,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..2c88987 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" @@ -10,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" ) @@ -26,22 +30,38 @@ 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 } type SearchOverlayModel struct { + server config.ServerTarget textInput textinput.Model list list.Model width int height int theme tint.Tint allEntries []search.IndexEntry + remote []search.IndexEntry + queryID uint64 + cancel context.CancelFunc + loading bool + err error } -func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { +var nextQueryID atomic.Uint64 + +type queryReadyMsg struct{ id uint64 } +type resultsMsg struct { + id uint64 + entries []search.IndexEntry + err error +} + +func NewSearchOverlayModel(theme tint.Tint, servers ...config.ServerTarget) *SearchOverlayModel { + server := config.MediaServer(servers...) ti := textinput.New() - ti.Placeholder = "Search titles, actors, directors..." + ti.Placeholder = "Artists, albums, tracks, movies..." ti.Focus() ti.Prompt = " " @@ -51,13 +71,16 @@ func NewSearchOverlayModel(theme tint.Tint) *SearchOverlayModel { l.SetFilteringEnabled(false) l.KeyMap.Quit.SetKeys("esc") - idx := search.GetIndex() + 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, - allEntries: idx.Entries, + allEntries: entries, } } @@ -69,13 +92,42 @@ 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(m.server.Context(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 + 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": + m.Close() return nil, nil case "up", "down": var lCmd tea.Cmd @@ -84,8 +136,9 @@ 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{ + return ui.SelectMediaMsg{Server: m.server, RatingKey: item.entry.RatingKey, Type: item.entry.Type, SectionID: item.entry.SectionID, @@ -101,8 +154,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) } @@ -119,10 +181,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 } } @@ -132,7 +203,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 { @@ -143,11 +214,28 @@ func (m *SearchOverlayModel) View() string { Padding(1, 2). Background(lipgloss.Color("#111111")) + status := "Search " + m.server.Name + " · [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 b709ee6..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" @@ -77,6 +78,13 @@ 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)}, + 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}, } m.list.SetItems(items) } @@ -85,15 +93,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 +145,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 +230,51 @@ 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)"}, + } + 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{ + 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: "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"}, + } } m.selectionList.SetItems(items) @@ -214,6 +290,20 @@ 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) + 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": + current = cfg.MusicVisualizer } for i, it := range items { @@ -235,6 +325,20 @@ 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) + 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": + cfg.MusicVisualizer = 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/tui/widget/visualizer/visualizer.go b/internal/tui/widget/visualizer/visualizer.go new file mode 100644 index 0000000..455eefc --- /dev/null +++ b/internal/tui/widget/visualizer/visualizer.go @@ -0,0 +1,139 @@ +// 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 + waves [3]player.Waveform + waveFade float64 + waveReady 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 "" + } + 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) + 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..9b8c5b1 --- /dev/null +++ b/internal/tui/widget/visualizer/visualizer_test.go @@ -0,0 +1,103 @@ +package visualizer + +import ( + "math" + "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{"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) + if lipgloss.Height(view) != height+1 { + t.Fatalf("%dx%d: unexpected height %d", width, height, lipgloss.Height(view)) + } + for line := range strings.SplitSeq(view, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("%dx%d: line overflows terminal", width, height) + } + } + } + } + }) + } +} + +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/alphabet.go b/internal/ui/alphabet.go new file mode 100644 index 0000000..5ab8b74 --- /dev/null +++ b/internal/ui/alphabet.go @@ -0,0 +1,138 @@ +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 +} + +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 + Focused bool + cursor int +} + +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: 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 { + 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] + label := " " + letter + " " + style := lipgloss.NewStyle().Foreground(theme.BrightBlack()) + 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(label) + } + 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 { + title := AlphabetTitle(meta) + if len(title) > 0 && title[0] >= 'A' && title[0] <= 'Z' { + return title[:1] + } + 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..0940127 --- /dev/null +++ b/internal/ui/alphabet_test.go @@ -0,0 +1,84 @@ +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) + } + } +} + +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") + } +} 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 new file mode 100644 index 0000000..b1c669b --- /dev/null +++ b/internal/ui/mouse.go @@ -0,0 +1,152 @@ +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, + "home": tea.KeyHome, "end": tea.KeyEnd, "pgup": tea.KeyPgUp, "pgdown": tea.KeyPgDown, + "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 { + 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]) + before, _, ok := strings.Cut(line, label) + if !ok { + return false + } + x := lipgloss.Width(before) + 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..50db70d 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" + "github.com/charmbracelet/x/ansi" ) // Overlay composites the 'overlay' string on top of the 'base' string, @@ -20,10 +20,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 +47,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") @@ -54,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/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 62aa394..2c73ece 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 @@ -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 2dd5c2a..949ec79 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,13 +1,17 @@ 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" ) // 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,26 +29,128 @@ 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 + left int +} + +func (t *Table) syncScroll() { + if t.Cursor() >= len(t.Rows()) { + t.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 + } + 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)) } + +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) + 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. +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 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: + 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() 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 func GetTableHeight(totalHeight int) int { - return max(totalHeight-5, 1) + return max(3, min(totalHeight-3, GetLayout().ContentHeight())) } 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() + }) + } +} diff --git a/internal/ui/types.go b/internal/ui/types.go index c50d117..eb25894 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 { @@ -17,6 +18,7 @@ type HelpProvider interface { } type SelectMediaMsg struct { + Server config.ServerTarget RatingKey string Type string SectionID string @@ -31,6 +33,7 @@ func (m ThemeChangedMsg) GetTheme() tint.Tint { } type JumpToDetailMsg struct { + Server config.ServerTarget SectionID string RatingKey string Type string @@ -49,8 +52,13 @@ type SwitchUserMsg struct { type InvalidPinMsg struct{} type MediaPageMsg struct { - Metadata []components.Metadata - Total int + ServerID string + SectionID string + RequestID uint64 + Start int + Metadata []components.Metadata + Total int + Err error } type RootChecker interface { @@ -68,13 +76,15 @@ type PlayableProvider interface { } type RequestPlayMsg struct { + Server config.ServerTarget RatingKey string - TctMode bool + Output config.VideoOutput } type ResumeChoiceMsg struct { + Server config.ServerTarget Metadata *components.Metadata - TctMode bool + Output config.VideoOutput } func Ptr[T any](v T) *T { @@ -94,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 +}