tvctl 0.1.0

Play YouTube, Spotify and more in windows on your screen and control them from the command line (built for AI agents)
# tvctl

A Rust CLI that lets agents (or people) play videos and music in windows on this
computer and control them from the command line, like a TV remote for your
screen. (It does not control physical TVs.) Every command prints JSON to
stdout; errors go to stderr with exit code 1.

Services are plugins (`tvctl plugins` lists them). Currently: YouTube and
Spotify (web player).
macOS only for now.

## Install

```sh
cargo install tvctl        # or, from a checkout: cargo install --path .
```

## Usage

```sh
tvctl open https://youtu.be/jNQXAC9IVRw --layout bottom-right --size 640x360
# => {"id": "1", "service": "youtube", "backend": "webview", "player": {...}, "window": {...}}

tvctl status 1
tvctl play 1 / tvctl pause 1 / tvctl toggle 1
tvctl next 1 / tvctl prev 1         # next/previous track or playlist entry
tvctl seek 1 1:30                    # absolute (90, 1:30, 1m30s)
tvctl seek 1 -10                     # relative (+10, -10)
tvctl forward 1 30 / tvctl back 1    # default 10 s
tvctl subtitles 1 ja                 # Japanese; auto-translated if there is no Japanese track
tvctl subtitles 1 list | on | off
tvctl audio 1 list | ja              # audio track (e.g. dubbing), where supported
tvctl quality 1 hd1080               # values: `available_qualities` in status
tvctl volume 1 50 / tvctl mute 1 / tvctl unmute 1
tvctl speed 1 1.5
tvctl window 1 --layout left         # left/right/top/bottom/top-left/.../center/maximize
tvctl window 1 --layout bottom-right --size 480x270   # anchored to a corner
tvctl window 1 --size 1280x720 --position 100,50 --on-top on
tvctl fullscreen 1                   # toggle (or on/off)
tvctl screenshot 1 shot.png          # PNG of the player window, even when covered
tvctl list
tvctl close 1

tvctl auth login youtube             # sign in once in tvctl's Chrome profile
tvctl auth login spotify
tvctl auth status
tvctl plugins                        # services, domains, supported actions
```

The background server starts on demand (`open`, `auth`) and stops a few seconds
after the last player closes (`TVCTL_IDLE_SECS`, default 5), quitting
tvctl's Chrome too. `tvctl server up` keeps it running without players
(avoids restarting Chrome between uses); `tvctl server down` stops it and
closes all players; `tvctl server status` shows its state.

`--size` is the video area; layouts without `--size` fill their screen region.
`tvctl --help` describes the workflow, the JSON output and error format, and
examples; `tvctl <command> --help` describes each argument.

## Backends

- **webview**: a native window with a WebView (tao + wry). No dependencies. Used
  for services with public embed APIs (YouTube logged out).
- **chrome**: an app window of Google Chrome, driven through the Chrome DevTools
  Protocol, using a dedicated profile at
  `~/Library/Application Support/tvctl/chrome-profile`. Used where a login or
  DRM is needed. Chrome refuses DevTools control of your everyday profile, so
  tvctl has its own; sign in to each service once with `tvctl auth login`.
  Always-on-top is not available in this backend.

A plugin lists its backends in order of preference. If a plugin declares
`prefer_backend_when_logged_in` and you are logged in, that backend is used
(YouTube: Chrome, so Premium accounts get no ads). `open --backend` overrides.

`auth login` opens the login page in a plain Chrome (not controlled, because
Google refuses sign-in in automated browsers) on tvctl's profile. Sign in, then
quit that Chrome with Cmd+Q; tvctl then checks the login cookies.

## Services

| Plugin | Backends | Login | Supported actions |
|---|---|---|---|
| `youtube` | webview (logged out), chrome (logged in) | optional (Premium: no ads) | all; `audio` only in chrome |
| `spotify` | chrome | required | play, pause, toggle, next, previous, seek, volume, mute |

Spotify links: tracks, albums, playlists, artists and episodes on
`open.spotify.com`. A track link plays that track; other pages start from the top.
Spotify opens in its large cover-art view (shown inside the window, so window
layouts keep working). The plugin drives the web player's UI (no public API), so Spotify UI
changes can break it; the E2E tests detect that.

## Plugins

Each service is a directory `plugins/<name>/`:

- `plugin.toml`:
  - `name`, `description`, `domains` (subdomains match too), `backends`
  - `actions`: supported player actions; optional `[unsupported_actions]` lists
    actions a backend lacks (e.g. `webview = ["audio"]`). The server refuses
    other actions with an error listing the supported ones, and
    `tvctl plugins` shows both, so agents can check before calling.
  - optional `[auth]`: `login_url`, `cookie_url`, `logged_in_cookies`,
    `prefer_backend_when_logged_in`, `login_mode` (`plain` by default, or `controlled`)
- `webview.html`: the page for the WebView backend.
- `frame.js` (optional): injected into that WebView's subframes.
- `chrome.js`: the adapter for the Chrome backend. It is loaded into every
  document of the player window, so it can restore its page state after reloads.

Adapters define `window.__tvctl.run(action)`, which returns a status object.
Actions: `open {url, start, paused, subtitles}`, `status`, `play`, `pause`,
`toggle`, `next`, `previous`, `seek {seconds, relative}`, `subtitles {lang}`, `audio {lang}`,
`volume {level, muted}`, `speed {rate}`, `quality {level}`. Unsupported actions
throw. In the Chrome backend, `open` may return `{"navigate": url}` to be loaded
at another URL first.

Built-in plugins are compiled into the binary. Plugins in `$TVCTL_PLUGIN_DIR`
or `~/.config/tvctl/plugins/<name>/` are loaded when the server starts and
override built-ins with the same name.

## Environment

- `TVCTL_SOCKET`: server socket (default `$TMPDIR/tvctl.sock`; the log is
  next to it as `tvctl.log`)
- `TVCTL_DATA_DIR`: data directory (Chrome profile, login state)
- `TVCTL_PLUGIN_DIR`: external plugin directory
- `TVCTL_CHROME`: Chrome executable
- `TVCTL_IDLE_SECS`: idle time before an on-demand server stops (default 5)

For plugin development, the hidden command `tvctl __eval <ID> <JS>` evaluates
JavaScript in a Chrome-backend player page and prints the (awaited) result.

## Tests

```sh
cargo test                                                   # unit tests
cargo test --test e2e_fake -- --ignored --test-threads=1     # all commands, both backends, offline
cargo test --test e2e_youtube -- --ignored --test-threads=1  # real YouTube (network)
TVCTL_E2E_PROFILE="$HOME/Library/Application Support/tvctl" \
  cargo test --test e2e_spotify -- --ignored --test-threads=1  # real Spotify (needs login)
```

The end-to-end tests open windows. `e2e_fake` uses a fake plugin and a local site.
`e2e_youtube` uses a fresh logged-out profile, so ads may play and the tests
wait for them. To use your logged-in profile instead, set
`TVCTL_E2E_PROFILE="$HOME/Library/Application Support/tvctl"`.