feat: add native Windows broadcaster and live score modules

- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

497
STREAMING_HANDOFF.md Normal file
View File

@@ -0,0 +1,497 @@
# Vash Esports: unattended broadcast setup and project handoff
Updated: 2026-09-29. This document records the work completed so far, the user's latest requirements, and the work still needed. **The unattended native Windows broadcaster is now implemented (`streamer/windows`, `vash-broadcast.exe`) but has not yet run on the Windows PC.** Section 0 describes it; sections 1–10 are the original handoff and still hold as requirements.
## 0. Implementation status (second session, 2026-09-29)
### What exists now
`bun run broadcast:build` produces `dist/vash-broadcast.exe` (cross-compiled from Linux). One program, driven over SSH:
```text
vash-broadcast setup --instance prod --server <url> --channel <id> --token <token> [--access-id … --access-secret …]
vash-broadcast login --instance prod # one-time spectator sign-in, via an SSH-tunnelled screen viewer
vash-broadcast login --instance dev --copy-from prod
vash-broadcast start|stop|restart|status|logs|snapshot|doctor --instance prod
vash-broadcast list; vash-broadcast remove --instance prod --yes
```
Design, as built (details in `streamer/README.md` section 3):
- **Per-instance everything** under `%LOCALAPPDATA%\Vash\Broadcast\instances\<name>`: portable OBS 32.2.2, tosu 4.26.2, FFmpeg 9.0.2 (all pinned by SHA-256 in `streamer/windows/install.ts`), a separate osu! folder (copied `osu!.exe`), own ports (block of 10 from 47100), logs, state. Stopping an instance kills only processes whose executable is inside its folder.
- **Desktop launch:** one scheduled task per instance (`Vash Broadcast (<name>)`, LogonType Interactive, normal priority, optional at-logon trigger) runs `bin\vash-broadcast-worker.exe task --instance <name>`. The worker copy is the same exe with the PE subsystem patched to GUI (no console window). `task` is a watchdog that restarts `worker` on crashes. CLI talks to the worker over `127.0.0.1:<control port>` with a per-run token (`runtime.json`).
- **Desktop helper:** `streamer/windows/desktop.ps1`, a long-running Windows PowerShell 5.1 process (C# via Add-Type): window enumeration by executable folder, focus, move, set title, screen capture, SendInput clicks/typing, Windows.Media.Ocr, locked-desktop detection, keep-awake.
- **Room selection:** OCR of osu!tourney's control panel (inverted, upscaled), click the line with the lobby's match id (room-name fallback only when unique), then confirm through tosu's `tourney.team.left/right`. Gameplay capture stays hidden in OBS until confirmed. The wiki screenshot shows panel lines as `<match id> : <room name> () n/16`.
- **OBS:** config files written before launch (user.ini FirstRun, websocket config.json, profile with Simple output `StreamEncoder=amd` → `h264_texture_amf`, service.json → local relay, crash sentinel cleared); scene reconciled over obs-websocket (window capture by exact unique title with `capture_audio`, browser overlay on top, desktop/mic muted, control panel cropped by 16:9 geometry). Encoder actually used is read from OBS's log after the stream starts; OBS's silent x264 fallback is reported as a warning.
- **Broadcast lifecycle:** `streamer/relay.ts` (extracted from Vash TV, now shared): Go live → session start → local RTMP listener → OBS StartStream; destinations + HLS recording/upload; End stream → OBS StopStream, final upload, session end.
- **Isolation limit found:** tosu scans every osu! process and uses the first tourney manager, so only one instance can run osu!tourney + tosu per PC. A host lock (`tourney.lock`) enforces it; others report `blocked`.
- **dev/staging behind Cloudflare Access:** Access service-token headers on API/websocket calls, plus a local overlay proxy (adds headers, injects `no-referrer` for the CDN's hotlink protection) because OBS's browser source cannot send headers.
- **Site:** agents may send `{type:'status', …}`; `src/lib/broadcast/worker.ts` validates it; the Broadcast list and Stream page show the worker state. The Stream page's Windows section now shows the `vash-broadcast` commands.
### Verified here (Linux)
- `bun run test:streamer`: 38 tests (room matching/OCR handling/tosu confirmation and launch rules against a fake desktop; OBS scene reconciliation, auth failure and timeouts against a fake obs-websocket; config isolation, ports, lock, supervisor backoff, PE patch, pinned download verification; relay lifecycle; VashLink; Access proxy; the worker's start → report → Go live → stop against a fake Vash).
- App: svelte-check 0/0, vitest 248 passed / 6 skipped, production build OK.
- Vash TV refactor run inside the existing `vash-tv` image: screen mode, relay mode, and the recording path (HLS segments, live + VOD playlists, poster, session start/status/end) all worked.
- The helper's C# compiled with Mono at `-langversion:5`; all PowerShell (helper + task scripts) parsed with PowerShell's parser. That is syntax only.
### Not verified (needs the Windows PC)
Everything Windows-specific has never run: PowerShell helper behaviour, scheduled-task start from SSH, tar/Expand-Archive extraction, OBS portable first start without dialogs, window capture of osu!tourney (WGC) with `capture_audio`, osu!tourney window layout/title stability, OCR accuracy on the real panel, clicks reaching osu!, AMD AMF, sign-in viewer, stay-awake, and the multi-hour soak. Also unconfirmed: osu! stable's login keys (`Username`/`Password` in `osu!.<user>.cfg`, used to detect a saved login), and whether `--copy-from` logins work across folders.
### Next steps on the real host
1. Build and copy: `bun run broadcast:build`, then `scp dist/vash-broadcast.exe stan@stan-win11:`.
2. On the PC over SSH: `setup` for one instance (prod needs no Access token; dev/staging need a Cloudflare Access service token). Set `OSU_TOURNEY_ACCOUNT` on the matching server to the spectator username.
3. `login`, then `doctor` (runs desktop checks through the task), then `start`, `status`, `snapshot`.
4. Run a real match; compare `snapshots/tourney-panel.png/.json` with what OCR read; fix whatever the first run shows, and record the results here.
## 1. The outcome the user wants
The user wants to run a program on their Windows PC, let it set up the broadcasting tools, and have it work automatically after that. They can start, stop, or restart it over SSH. They cannot spend time clicking through OBS, selecting osu! rooms, or repairing configuration for each stream.
Latest requirements, in the user's words:
> “I want a solution that I can just kinda run a program and it sets everything up.”
>
> “Once its started it needs to just work. This is also important once I move it over to the cloud.”
>
> “I dont mind having 3 instances to manage for dev, staging and prod. But I cant do any manual work per stream or anythign like that.”
**One-time setup is acceptable. Manual work per stream is not.** Do not continue by asking the user to manually create OBS scenes, select capture windows, switch tournament rooms, or start multiple helper applications for every match. Implement the missing automation.
The preferred operating model is:
1. Install/setup an environment once.
2. Supply its Vash server, channel, and agent token once.
3. Complete the spectator account's initial sign-in once if it cannot be automated appropriately.
4. Start one supervised worker for that environment.
5. The worker follows Vash, launches/restarts its dependencies, selects the correct lobby, supplies live scores, and handles the broadcast lifecycle automatically.
6. Operator controls and diagnostics are available through SSH and the Vash website.
The user is comfortable managing separate dev, staging, and production instances. This should mean separate instances of the same software with isolated configuration, rather than three bespoke setup procedures.
## 2. Hardware, access, and account decisions
### Streaming PC
| Item | Confirmed information |
| ------------------------ | ---------------------------------------------------------------------------- |
| OS | Windows 11, latest build according to the user; exact build was not supplied |
| CPU | Intel Core i9-12900KF |
| GPU | AMD Radeon RX 7900 XT |
| Hostname | `STAN-WIN11` / `stan-win11` |
| Desktop user | `STAN-WIN11\stan` |
| Desktop session | A user is signed in; confirmed using `Get-CimInstance Win32_ComputerSystem` |
| Current access | User is on a MacBook and can SSH into Windows |
| Graphical remote access | None currently available |
| Installed | OBS and normal osu! stable |
| Not installed/configured | A separate osu!tourney installation and tosu |
| Spectator account | **Use a separate spectator login**, explicitly chosen by the user |
The user declined WSL for this setup because they want straightforward access to their AMD GPU. **Native Windows is the current target.** OBS's AMD AMF hardware encoder is the intended starting point; verify actual encoder availability and successful output on the PC. Do not assume NVIDIA/CUDA tooling applies to this machine.
The desktop being signed in does not prove it is unlocked, has an active display, or can currently produce valid captures. Those conditions need a real host check. Launching GUI applications directly inside an SSH service session is not a substitute for launching them in the intended desktop session.
No working OBS launch, scene configuration, osu!tourney login, or gameplay capture on this Windows machine has been confirmed. A PowerShell example using an interactive scheduled task was provided, but the user has not reported running it successfully.
The user has not supplied the separate spectator account name or credentials. Do not copy their existing playing account's saved login into the broadcaster by default. osu!tourney needs an account with osu!supporter. Resolve the supported spectator-login arrangement for concurrent dev/staging/prod workers; do not assume the same account can stay independently logged into all three workers.
### Existing application server and repository
- Workspace: `/home/stan/dev/esports`.
- Branch: `dev`.
- Origin: `https://git.stanrunge.dev/stan/esports`.
- Linux server was renamed from `vash-prod` to `stan-server-hetzner`.
- User explicitly authorized `ssh ai@localhost` for Docker/sudo operations on this Linux server. This is **not** an SSH connection to the Windows PC.
- We do not currently have a confirmed route or login for connecting directly from this workspace to `stan-win11`. The user has Windows access from their MacBook.
- The development application runs with `bun run dev` on `127.0.0.1:3102`; it is not the production Docker container.
- During debugging, its terminal was stan's tmux session `main`, pane `%1`. Recheck before using this operational detail again.
| Environment | Website | Database |
| ----------- | --------------------------------------- | -------------- |
| Dev | `https://esports-dev.vash.software` | `vash_dev` |
| Staging | `https://esports-staging.vash.software` | `vash_staging` |
| Production | `https://esports.vash.software` | `vash` |
The streaming PC should connect to the selected existing website. It does not need a second copy of the Vash web server, the server's `.env`, or its IRC host credentials just to broadcast.
## 3. How we arrived here
### Match-playing page redesign
The original request was to take the lead on redesigning the match-playing page for a standard 1920×1080 desktop browser: clean, intuitive, playful, live, and clear about what each player should do.
Completed work includes:
- A compact desktop dashboard with a scoreboard, central phase-specific guidance, and progress through the match.
- Sidebar tabs for activity, maps, results, and picks, with less frequently needed controls behind other UI components.
- More compact map pool, match feed, and history components.
- Better final-result layout and winner wrapping.
- WebSocket-driven updates, visible connection status, and five-second snapshot polling while the socket is unavailable.
- Attention cues for a player's turn, including existing sound/notification behavior.
The desktop layout was visually checked in a 1920×1080 headless Chromium viewport earlier in this conversation and fit that viewport. Preserve this work while implementing broadcasting.
Key files include `src/lib/components/match/MatchDashboard.svelte`, its `Stage*` components, `Scoreboard.svelte`, `MapPool.svelte`, `MatchFeed.svelte`, `MapHistory.svelte`, `live.svelte.ts`, and `src/routes/(app)/matches/[id]/+page.svelte`.
### Streaming and cloud discussion
The user tried starting a stream and saw it waiting for a Docker container that never appeared in `docker ps`.
The important finding: the website's **Go live** action changes channel state; it does not provision a Docker container. Stream setup text was updated to explain this. Existing Windows/OBS and manual Docker paths were documented, but they still involve too much manual operation for the user's actual requirement.
Cloud hosting was explored, including Google Cloud, AWS, and Hetzner. The user pointed out that an osu! match can run for approximately five hours, so a short-lived 30-minute assumption or a one-hour GPU runtime limit is unsuitable. They then explicitly deferred cloud hosting and chose to use their Windows PC first.
Do not resume cloud provisioning now. Build an unattended worker with a reusable configuration and lifecycle so it can move to cloud infrastructure later. No cloud worker provisioning was completed. A preliminary Cloud Run integration was removed earlier in the work.
### Windows access clarification
Initial instructions assumed the user could operate the Windows desktop. They clarified they are on a MacBook with SSH access only. They confirmed `STAN-WIN11\stan` is signed in, then clarified the true requirement: a program that provisions and runs the broadcast stack automatically, with at most one-time manual setup.
The user has now explicitly chosen a **separate spectator login**. This is the latest account decision.
## 4. Repeated lobby creation: diagnosed and fixed
The user repeatedly created a real-lobby match against a bot. Bancho made multiple rooms until the account hit its four-room limit, while the match page continued saying it was waiting to open a lobby. Cancelling in Vash did not reliably clean up every room.
### Actual root cause
Logs showed successful room creation followed by `Joining #mp_<id> timed out`.
A controlled live Bancho probe established the exact ordering:
1. Vash sends `!mp make`.
2. Bancho automatically joins the host to the new `#mp_...` channel.
3. Bancho sends JOIN/NAMES responses.
4. Bancho sends the private “Created the tournament match” confirmation.
5. The old client discarded the earlier JOIN, sent another JOIN, and waited for a new echo.
6. Bancho ignores a redundant JOIN to a channel already joined, so the client timed out.
7. The old provider converted that failure into a capacity wait and the runtime created another room on retry.
An earlier partial fix also did not replace the live provider's old methods: the IRC provider is a process-wide singleton that survives Vite HMR. A real dev-server restart was required.
### Fixes now in the repository
- Track confirmed channel membership and remember Bancho's automatic JOIN.
- Make repeated/concurrent JOIN requests reuse the joined state or pending request.
- Accept end-of-NAMES as confirmation for an expected channel; clear membership appropriately on leave/disconnect.
- Make the fake Bancho server reproduce automatic JOIN before the creation PM and ignore redundant JOINs.
- Serialize `!mp make` per IRC account so one confirmation cannot resolve multiple concurrent requests.
- Reserve capacity for pending creation requests.
- Start the creation-response timeout after a queued command is actually sent.
- Stop further creation attempts on an uncertain result instead of making more rooms blindly.
- Preserve the configured room limit when Bancho reports full capacity; temporarily back off instead of permanently reducing the configured maximum.
- Reuse an already created/persisted room when saving or resuming setup is retried.
- Close a late-arriving room when cancellation wins the race with room creation.
- Wait for in-flight opening work when replacing a runtime during reload.
- Allow the due-match loop to resume a saved room even when no new room slots are free.
- Log actual opening errors distinctly from ordinary capacity waits.
### A second live bug: room closure
A real test found that sending `!mp close` and immediately leaving the IRC channel could leave the Bancho room open. The close path now waits for Bancho's closure acknowledgement before forgetting/leaving the room. An externally confirmed closure also releases local capacity.
The earlier broad “close every apparently orphaned room during runtime boot” cleanup was removed. Do not reintroduce it without handling startup/load failures and in-flight creation correctly; a failed actor load is not proof that its lobby is abandoned.
### Verification completed
- The updated provider opened a real Bancho test room in approximately 361 ms.
- Bancho acknowledged that room's closure.
- A fresh connection confirmed the closed room no longer existed.
- Known rooms from the cancelled test attempts were cleaned up during the controlled checks.
- The dev server was restarted and reported connected IRC, zero active matches, and four free slots on the configured account.
- Final pre-push suite: **246 passed, 6 skipped**, across 19 passing test files and one skipped file. The skipped tests were the database-backed auth integration suite.
- Streamer tests: **3 passed**.
- Svelte check: **0 errors, 0 warnings**.
- Production build passed; Sentry uploads were disabled for this local build.
- Windows and Linux streamer binaries compiled successfully.
These checks verify the lobby fix and existing build/tests. They do **not** constitute a real Windows gameplay/OBS broadcast test.
Relevant files:
- `src/lib/server/osu/irc-client.ts` and `irc-client.test.ts`
- `src/lib/server/osu/irc.ts` and `irc.test.ts`
- `src/lib/server/osu/fake-bancho.ts`
- `src/lib/server/match/runtime.ts`, `runtime.test.ts`, and `runtime.irc.test.ts`
- `src/lib/server/match/service.ts` and `create.ts`
**Restart the dev process after changing IRC client/provider methods. HMR does not refresh methods on an existing singleton instance.** This is documented in `README.md`.
## 5. Git state and completed commits
The user's request to commit all accumulated changes was completed. The broad change set included the match UI/runtime, broadcast functionality, authentication/account work, database migrations, development tools, and deployment configuration that were already in the workspace.
Both commits were pushed to `dev`:
| Commit | Description |
| --------- | ------------------------------------------------------------------------------------------------------------------------- |
| `9b9aafe` | `feat: redesign match play and add broadcast tooling` — broad accumulated change set, including the principal lobby fixes |
| `9d5a046` | `fix: resume saved lobbies when host capacity is full` — final recovery guard and regression test |
The workspace was clean and `dev` matched `origin/dev` immediately before adding this handoff document.
The initial HTTPS push failed because Git tried to use an unavailable VS Code askpass socket. The hostname change was not an obstacle to the successful SSH push. Gitea's SSH service is exposed on the Linux server at localhost port 2222. The successful push used:
```bash
git -c core.sshCommand='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' \
push ssh://git@localhost:2222/stan/esports.git dev
```
That localhost URL is specific to the existing Linux server. Do not tell the user to clone `localhost:2222` on their Windows PC or Mac. Their clone URL is:
```text
https://git.stanrunge.dev/stan/esports.git
```
Pushing `dev` did not deploy production. The current Gitea workflow deploys pushes to `staging`; production promotion is a separate manual workflow.
## 6. Existing broadcasting pieces and their limitations
### Vash website and director
- Broadcast channels follow one match, one tournament, or automatic match selection.
- Layouts and overlays are configurable; overlays are 1920×1080 browser pages.
- The director sends scene, match ID, lobby ID, room name, and team size to authenticated agents over `/ws/agent`.
- Agent tokens are generated from **Agent token on the Broadcast list page**. They are shown once; rotating one invalidates the old token.
- `src/lib/server/broadcast.ts` handles channels, agent connections, live score ingestion, and broadcast sessions.
- The API at `src/routes/api/broadcast/[id]/stream/+server.ts` provides desired live state, video settings, overlay URL, destinations, and recording configuration to an authorized worker.
- That API also accepts session start/status/end reports. Related endpoints handle recording uploads.
- `OSU_TOURNEY_ACCOUNT` in the server environment grants the configured spectator referee access during new-lobby setup. It has not been confirmed configured for the new separate spectator account.
### Existing Windows/Linux agent: `streamer/agent.ts`
What it currently does:
- Authenticates to a channel's `/ws/agent` connection.
- Receives which match/scene/lobby to follow.
- Reads tosu v2 WebSocket scores and forwards them to Vash, at most four times per second.
- Optionally switches OBS scenes through OBS WebSocket.
- `--setup-obs` creates a scene, an unconfigured window capture source, and an overlay browser source, then exits.
- Compiles into `dist/vash-streamer.exe` and `dist/vash-streamer` with `bun run streamer:build`.
What it **does not** currently do:
- Install tosu or provision an osu!tourney installation.
- Launch itself or dependencies into the Windows desktop from SSH.
- Automatically select the Windows osu!tourney room.
- Automatically choose the capture window created by `--setup-obs`.
- Configure OBS GPU output/streaming end to end.
- Start or stop OBS streaming based on the website's Go live state.
- Supervise/restart the entire native Windows process stack.
- Isolate three native Windows environments.
Do not advertise this executable as the finished unattended worker.
### OBS client: `streamer/obs.ts`
Implements OBS WebSocket 5 authentication and generic requests. It is small and reusable, but needs stronger lifecycle handling for unattended operation: authentication failure/close handling, connect/request timeouts, reliable readiness state, and reconnection. Currently `connected` is based on the underlying socket state rather than completed authentication.
### Tournament automation: `streamer/tourney.ts`
Currently **Wine/X11-specific**:
- Writes `tournament.cfg` with `TeamSize`, `acronym = VASH`, and capture height while the client is stopped.
- Launches osu! with Wine.
- Captures the lower room-selection panel using FFmpeg/X11.
- Uses Tesseract OCR to find the expected room name and xdotool to click it.
- Restarts the client when team size changes.
Its current process management, including `wineserver -k`, is not suitable for blindly managing multiple isolated workers on one host. Native Windows support and safe ownership of individual worker processes remain to be designed/implemented.
### Vash TV: `streamer/tv/main.ts`
- Existing standalone broadcast runner polls channel settings and follows Go live/End stream.
- Renders the Vash overlay through Chromium/Xvfb, or accepts an OBS RTMP ingest stream in relay mode.
- Encodes the rendered mode with CPU `libx264`; relay mode copies the incoming encoded stream.
- Sends to multiple destinations, reports status, and creates/uploads HLS recordings for site playback.
- The relay could be reused behind native OBS, but a native packaging/lifecycle path is not yet implemented.
- Linux assumptions such as `/tmp/vash-hls`, command names, process handling, and ingest binding need review before reuse on Windows or for multiple simultaneous instances.
### Docker images and docs
- `streamer/tv/Dockerfile`: Chromium/Xvfb/FFmpeg data-only renderer or OBS relay.
- `streamer/tourney/Dockerfile` and `streamer/tourney/start.sh`: Wine osu!tourney, tosu, virtual displays, VNC setup, agent, and compositor.
- The tourney container still needs a prepared persistent osu! installation, login, and tosu binary. Its complete real-gameplay path has not been validated on the user's host.
- These images currently have no configured AMD GPU acceleration path. Do not assume merely running the existing container on a GPU machine makes it use that GPU.
- `streamer/README.md`: existing manual Windows and Docker documentation.
- `streamer/WSL.md`: an earlier WSL-assisted setup guide. It remains in the repo but **is not the user's chosen path now**.
## 7. What must be built next
### A. One entry point for an environment
Provide a packaged native Windows launcher/worker with an understandable CLI. An illustrative interface is below; **these commands do not exist yet**:
```text
vash-broadcast.exe setup --instance dev --server <url> --channel <id>
vash-broadcast.exe login --instance dev
vash-broadcast.exe start --instance dev
vash-broadcast.exe status --instance dev
vash-broadcast.exe logs --instance dev
vash-broadcast.exe restart --instance dev
vash-broadcast.exe stop --instance dev
```
The exact names are a design choice. The requirement is a single runnable program, persistent setup, repeatable starts, actionable status, and no per-stream desktop work. Avoid making Bun/Git and a project build permanent operational requirements if a downloadable compiled release can do the job.
### B. Repeatable setup
- Detect OBS and the existing osu! stable installation, accepting explicit paths when detection fails.
- Provision a **separate** managed osu!tourney installation. Do not convert or overwrite the user's personal playing installation.
- Download/install a verified tosu release automatically.
- Install/package any additional capture, OCR, or relay dependencies actually needed by the selected implementation.
- Configure a managed OBS profile/scene collection or separate portable OBS installation without changing unrelated personal OBS settings.
- Set up authenticated local OBS control and persist per-instance settings/secrets locally.
- Create the overlay source and exact game capture, including audio, geometry/cropping, and output settings automatically.
- Select and verify a working hardware encoder on the RX 7900 XT. Report failure explicitly rather than silently claiming GPU encoding while using a software fallback.
- Re-running setup must preserve successful login/configuration and avoid duplicate scenes, tasks, sources, or processes.
### C. Resolve the one-time osu! login boundary
The user accepted a separate spectator login and tolerates genuinely one-time interaction. Official osu!tourney setup calls for signing into a separate stable installation, remembering the login, then adding `tournament.cfg` while osu! is stopped.
Determine how to offer that first sign-in from the user's current SSH-only position. Possibilities need evaluation; do not promise an unsupported fully headless sign-in. Provide a clearly bounded one-time remote setup path if graphical interaction is unavoidable. After sign-in, preserve the installation and login state between restarts.
Do not invent an osu! OAuth-to-stable-client login mechanism, store a spectator password in the Vash web server, or silently reuse the personal playing account.
### D. Desktop launch and supervision
- Use a supported interactive desktop launch mechanism, such as Task Scheduler with `LogonType Interactive`, for the signed-in Windows user.
- Make start/restart commands callable through SSH. Launch dependencies in the appropriate session with the correct working directories.
- Track processes owned by each instance. Stop/restart only those processes and their managed descendants.
- Restart failed OBS, tosu, tourney, agent, and relay components with bounded backoff.
- Detect missing/locked/unavailable desktop capture and present a useful status instead of looping forever or showing a false success.
- Ensure SSH disconnection does not stop the broadcast.
- Handle update/reboot recovery explicitly. “Auto-start at login” is not the same as “works after reboot before anyone logs in”; document and validate the chosen behavior.
### E. Automatic lobby following on Windows
This is a core missing capability. Investigate a supported room-selection/control path before choosing image-based automation. Initial research looked for tosu/tourney IPC or command support but did not establish such a path.
If Windows UI/capture/OCR automation is required:
- Target the managed osu!tourney window and the exact requested match.
- Use lobby ID when a supported interface exposes it. Room-name fallback must handle ambiguity; do not click the first available room.
- Handle room-list refreshes, startup delays, team-size changes, DPI scaling, window placement, and client restarts.
- Verify that the requested match is actually being spectated before reporting success.
- Retry with bounded, meaningful states and expose diagnostics/snapshots through the CLI.
- Do not make the user click a room for each match.
Vash already supplies `lobbyId`, `roomName`, and `teamSize` in the agent follow message. All Vash room names use `VASH: (Red) vs (Blue)` with each side's name truncated to 20 characters, matching the server's creation logic.
### F. Automatic OBS configuration and broadcast lifecycle
- Finish programmatic scene/input setup: select the right capture window, crop out the tourney control panel, position gameplay correctly under the overlay, and capture the intended game audio.
- Account for osu!tourney's multiple client windows and team sizes; verify the capture is the actual match layout, not just its controller window.
- Configure output profiles and the AMD encoder automatically once, then reconcile settings safely on later starts.
- Respect the channel's desired live state and existing stream configuration. Establish an explicit start/stop contract for the worker and the website.
- Support the existing recording/site playback/multistream requirements using a managed relay or another concrete implementation. A Windows agent connected to `/ws/agent` alone does not provide video to the site.
- Report worker readiness and failures clearly on the site. Distinguish worker offline, login required, waiting for lobby, spectating, streaming, reconnecting, and error.
- Handle End stream and process shutdown cleanly, preserving recordings and releasing resources.
The user should operate normal broadcasts through Vash; opening OBS should be a diagnostic or truly one-time action.
### G. Dev/staging/production isolation
Separate instances must have separate:
- Server origins, channel IDs, and agent tokens.
- Configuration/state directories and installation ownership.
- OBS profiles/collections or managed installations and WebSocket ports/passwords.
- tosu ports and reliable association with the intended osu! processes.
- Ingest endpoints if relays are used, recording folders, logs, process identifiers, and scheduled tasks.
- Restart/stop behavior that cannot affect another instance or the user's personal osu!/OBS session.
Do not assume giving tosu three different ports is sufficient. Verify that each tosu instance can be scoped to its corresponding tournament clients rather than reading every osu! process and mixing environments.
Concurrent spectator logins, matching lobby names across environments, and aggregate GPU/CPU/display requirements also need validation. Three separate workers are acceptable to the user; simultaneous operation must not be falsely advertised before these interactions are tested.
### H. Cloud migration later
Keep the desired-state protocol, channel authorization, status reporting, recording, and supervisor behavior portable. Keep platform-specific desktop/process/encoder handling behind a clear boundary.
Support matches lasting at least five hours without an arbitrary short timeout. Preserve setup and spectator login state appropriately. A future cloud VM may need a GPU driver, an interactive/virtual display strategy, and persistent state; containers alone do not solve those issues.
Choose cloud hosting and provisioning later. The immediate acceptance target is this native Windows AMD PC.
## 8. Useful research and verified interfaces
### Official references
- [osu!tourney setup](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup): separate installation, remembered login, supporter requirement, `tournament.cfg`, `TeamSize`, `acronym`, and capture height.
- [osu!tourney multiplayer usage](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Multiplayer_usage): room naming and client behavior.
- [OBS WebSocket protocol](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.json).
- [OBS WebSocket README](https://github.com/obsproject/obs-websocket/blob/master/README.md): authentication and command-line overrides.
- [OBS launch parameters](https://obsproject.com/kb/launch-parameters).
- [OBS hardware encoding](https://obsproject.com/kb/hardware-encoding).
- [Microsoft scheduled task principals](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal): `Interactive` logon type.
- [tosu repository](https://github.com/tosuapp/tosu) and [wiki](https://github.com/tosuapp/tosu/wiki/).
OBS protocol requests checked during research include:
- `GetInputPropertiesListPropertyItems` for enumerating selectable capture targets.
- `SetInputSettings`, scene creation/item APIs, and `SetSceneItemTransform` for automatic capture/overlay setup.
- `SetProfileParameter` and `SetVideoSettings` for output configuration; validate when a restart/reload is required rather than assuming every setting applies immediately.
- `SetStreamServiceSettings` for RTMP service settings, and stream start/stop/status requests.
- `GetSourceScreenshot` for remote diagnostics.
- `GetVersion` for capability checks.
Do not assume these APIs alone solve room selection, stable account login, session isolation, or GPU readiness.
### tosu release observed during research
The official GitHub release API returned `v4.26.2` during this session:
- Windows asset: `tosu-windows-v4.26.2.zip`.
- URL: `https://github.com/tosuapp/tosu/releases/download/v4.26.2/tosu-windows-v4.26.2.zip`.
- Reported SHA-256: `d34c2bfc495959fcc79ff01f10b7dc9aea77eb508ed23c8f0d0fb8342773faa3`.
This is a research observation, not a completed installer implementation. Revalidate the chosen release when implementing, then pin and verify it rather than downloading an unexamined changing binary on every start.
Potential upstream source locations identified but not yet investigated fully:
- `packages/tosu/src/states/tourney.ts`
- `packages/server/utils/commands.ts`
- `packages/common/utils/config.ts`
- `packages/common/utils/config.types.ts`
Temporary downloaded reference files existed at `/tmp/vash-tosu-tree.json`, `/tmp/vash-tosu-release.json`, and `/tmp/vash-obs-protocol.json`. Treat these as disposable research artifacts, not project dependencies.
## 9. Acceptance criteria for the next implementation
The work is complete only when the actual Windows host demonstrates the following:
1. A repeatable setup command provisions missing dependencies and a separate tourney installation, with a clearly bounded one-time spectator sign-in if needed.
2. One SSH start command starts the intended worker in the correct desktop session and survives disconnecting SSH.
3. A real match gets exactly one lobby, and the broadcaster automatically selects it.
4. OBS captures the correct gameplay and audio, layers the Vash overlay, and actually uses the RX 7900 XT's hardware encoder.
5. Live scores reach Vash through tosu and the agent.
6. Starting/ending a broadcast follows the selected website control without desktop interaction.
7. The next match, including a different team size, is followed without manual room/window selection.
8. Component crashes and network interruptions recover automatically without duplicate workers or wrong-room capture.
9. Recording, destination status, and site playback work for the supported output path.
10. Dev/staging/prod instances do not share state, capture the wrong clients, or stop each other.
11. A multi-hour soak test covers the expected match duration and records actual CPU/GPU/memory behavior.
12. Status/logs clearly identify one-time setup requirements and recoverable failures through SSH or the website.
Useful existing verification commands:
```bash
bun run check
bun --bun vitest run
bun run test:streamer
bun run streamer:build
SENTRY_AUTH_TOKEN= bun run build
```
IRC tests open local TCP sockets. In the restricted agent sandbox, Vitest hung; running the tests with the proper host permissions worked. Git hooks run formatting/lint on commit and type checking/unit tests on push.
Use focused new tests for configuration isolation, recovery, process ownership, OBS request sequencing/timeouts, and room-selection confirmation. A successful Linux cross-compilation is not evidence that Windows desktop capture and GPU encoding work. Record exactly what was tested on the real host and what remains unverified.
## 10. Immediate next step
Resume implementation of the unattended Windows launcher and worker, starting with a concrete design for per-instance state, one-time spectator login, desktop launch, automatic room selection, and managed OBS output. Reuse the existing director, agent protocol, tosu parser, OBS client, and recording pipeline where appropriate.
At the point the user requested this handoff, research had begun, but **no new Windows launcher, bootstrap installer, native room automation, or process supervisor had been written**. Preserve the committed lobby/UI fixes and make the missing setup actually runnable. Avoid another sequence of instructions that leaves the user doing manual work for each stream.

View File

@@ -0,0 +1,13 @@
import { expect, test } from 'vitest';
import { agentScoresSchema } from './scores';
test('accepts old agent frames and named clients with no profile id, but rejects malformed scores', () => {
const client = { osuId: 0, name: 'Ocean Player0', score: 50, accuracy: 99, combo: 2 };
const frame = { type: 'tosu', clients: [client], progress: 0.5 };
expect(agentScoresSchema.safeParse(frame).success).toBe(true);
expect(agentScoresSchema.safeParse({ ...frame, matchId: 'm1', beatmapId: 75 }).success).toBe(
true
);
for (const clients of [[null], [{ ...client, score: Infinity }], [{ ...client, accuracy: 500 }]])
expect(agentScoresSchema.safeParse({ ...frame, clients }).success).toBe(false);
});

View File

@@ -0,0 +1,20 @@
import * as z from 'zod';
/** The existing agent protocol, with optional context for newer workers. */
export const agentScoresSchema = z.object({
type: z.literal('tosu'),
matchId: z.string().optional(),
beatmapId: z.number().int().positive().nullable().optional(),
progress: z.number().min(0).max(1).nullable().optional(),
clients: z
.array(
z.object({
osuId: z.number().int().nonnegative(),
name: z.string().trim().max(80).optional(),
score: z.number().nonnegative(),
accuracy: z.number().min(0).max(100),
combo: z.number().nonnegative()
})
)
.max(16)
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from 'vitest';
import { parseWorkerStatus, workerTone } from './worker';
describe('parseWorkerStatus', () => {
test('keeps known states and trims what a worker sends', () => {
expect(
parseWorkerStatus(
{
state: 'spectating',
detail: ' room selected ',
onAir: true,
encoder: 'AMD AMF (hardware)',
instance: 'prod',
version: '0.1.0',
extra: 'ignored'
},
1000
)
).toEqual({
state: 'spectating',
detail: 'room selected',
onAir: true,
encoder: 'AMD AMF (hardware)',
instance: 'prod',
version: '0.1.0',
at: 1000
});
});
test('an unknown state shows as needing attention, and nothing unbounded gets through', () => {
const status = parseWorkerStatus(
{ state: 'toString', detail: 'x'.repeat(1000), onAir: 'yes' },
1
);
expect(status.state).toBe('error');
expect(status.detail).toHaveLength(300);
expect(status.onAir).toBe(false);
expect(parseWorkerStatus({ state: 'weird' }, 1).detail).toBe('Unknown state "weird"');
expect(workerTone('login-required')).toBe('bad');
expect(workerTone('spectating')).toBe('ok');
});
});

View File

@@ -0,0 +1,65 @@
/**
* What an unattended broadcast worker (streamer/windows) reports over
* /ws/agent, and how the site words it.
*/
export interface WorkerStatus {
state: WorkerState;
detail: string | null;
onAir: boolean;
encoder: string | null;
instance: string | null;
version: string | null;
at: number;
}
export const WORKER_STATES = {
starting: 'Starting',
blocked: 'Waiting for another instance',
'setup-required': 'Setup needed',
'login-required': 'Spectator sign-in needed',
'desktop-locked': 'Windows is locked',
idle: 'Ready, nothing to show',
'waiting-for-lobby': 'Waiting for the lobby',
selecting: 'Selecting the room',
spectating: 'Spectating',
reconnecting: 'Reconnecting',
stopping: 'Stopping',
error: 'Needs attention'
} as const;
export type WorkerState = keyof typeof WORKER_STATES;
/** Colour of a state's badge: fine, busy, or needs someone. */
export function workerTone(state: WorkerState): 'ok' | 'busy' | 'bad' {
if (state === 'spectating' || state === 'idle') return 'ok';
if (
state === 'error' ||
state === 'login-required' ||
state === 'desktop-locked' ||
state === 'setup-required'
)
return 'bad';
return 'busy';
}
const text = (value: unknown, max: number) =>
typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;
/** Validates a status message; an unknown state reads as an error so it is never hidden. */
export function parseWorkerStatus(
message: Record<string, unknown>,
now = Date.now()
): WorkerStatus {
const raw = typeof message.state === 'string' ? message.state : '';
const known = Object.hasOwn(WORKER_STATES, raw);
return {
state: known ? (raw as WorkerState) : 'error',
detail: text(message.detail, 300) ?? (known ? null : `Unknown state "${raw.slice(0, 40)}"`),
onAir: message.onAir === true,
encoder: text(message.encoder, 80),
instance: text(message.instance, 40),
version: text(message.version, 40),
at: now
};
}

View File

@@ -0,0 +1,21 @@
import { expect, test } from 'vitest';
import { demoView } from './broadcast/demo';
import { liveTotal, scoreIsFresh, SCORE_STALE_MS } from './match-scores';
test('live values use the match win condition and do not present a missing feed as zero', () => {
const side = demoView('gameplay').sides[0];
side.players[0].live = { score: 500, accuracy: 98, combo: 30, progress: 0.5 };
side.players[1].live = { score: 300, accuracy: 96, combo: 20, progress: 0.5 };
expect(liveTotal(side, 'score_v2')).toBe(800);
expect(liveTotal(side, 'accuracy')).toBe(97);
expect(liveTotal(side, 'combo')).toBe(50);
side.players.forEach((player) => (player.live = null));
expect(liveTotal(side, 'score')).toBeNull();
});
test('score freshness expires independently of the match websocket connection', () => {
const score = { score: 0, accuracy: 100, combo: 0, progress: 0, updatedAt: 1000 };
expect(scoreIsFresh(score, 1000)).toBe(true);
expect(scoreIsFresh(score, 1000 + SCORE_STALE_MS + 1)).toBe(false);
expect(scoreIsFresh(null, 1000)).toBe(false);
});

19
src/lib/match-scores.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { LiveScore, SideView } from './match-view';
export const SCORE_STALE_MS = 5000;
export function scoreIsFresh(score: LiveScore | null, now: number) {
return !!score && (score.updatedAt == null || now - score.updatedAt <= SCORE_STALE_MS);
}
/** Provisional team values; final results still come from the match referee. */
export function liveTotal(side: SideView, winCondition: string): number | null {
const scores = side.players.flatMap((player) => (player.live ? [player.live] : []));
if (!scores.length) return null;
if (winCondition === 'accuracy')
return scores.reduce((sum, score) => sum + score.accuracy, 0) / scores.length;
return scores.reduce(
(sum, score) => sum + (winCondition === 'combo' ? score.combo : score.score),
0
);
}

122
streamer/ALPHA_TEST.md Normal file
View File

@@ -0,0 +1,122 @@
# Understanding and testing the broadcaster
Reviewed against the working tree on 2026-09-29, including the existing uncommitted Windows worker. This describes the local implementation; it does not establish what is deployed or installed on the Windows PC.
The intended product is one broadcaster that handles an entire match after initial setup. The current implementation has much of that machinery, but the complete Windows broadcast remains unverified and starting/stopping with match activity is missing.
## What runs where
| Location | Responsibility |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Existing Vash VM | Website, database, match hosting/refereeing, choosing a match, supplying overlay pages and stream settings |
| Windows PC | Run osu!tourney, capture gameplay and audio, read live scores with tosu, compose the overlay in OBS, encode video, relay to destinations |
| Streaming destinations and configured storage | Receive the stream; storage supplies Vash's live video and recordings |
The PC connects to the existing website. It does not need another Vash application server or the server's database/IRC credentials. It needs its channel token and, for a protected dev/staging site, a Cloudflare Access service token.
OBS's hardware encoder is the sensible starting point for the recorded RX 7900 XT configuration. OBS recommends hardware encoding to reduce CPU load; actual resolution, team size, audio and reliability still need measurement. See [OBS hardware encoding](https://obsproject.com/kb/hardware-encoding).
## Similar names, different capabilities
| Component | What it actually does |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Broadcast channel and director | Choose one match, a tournament's matches, or a match scored by fixed rules. Automatic selection stays through a map and holds the final result for 30 seconds. It is a rules-based director. |
| Overlay | Browser page with scenes that follow match state. An overlay alone contains no captured osu! gameplay. |
| `vash-broadcast.exe` | New native Windows installer and supervised worker. Intended to manage OBS, osu!tourney room selection, tosu and the local relay. This is the path aligned with unattended broadcasting. |
| `vash-streamer.exe` | Older agent used with manually prepared tools. It is a different executable with a different setup flow. |
| Vash TV | Container that renders graphics and scores, or relays video supplied by OBS. Screen mode alone is not gameplay broadcasting. |
| Tourney container | Separate Linux/Wine gameplay implementation. The documented path still needs a real host test. |
| Platform destinations | RTMP/RTMPS URLs and stream keys. These are not complete social-platform account or broadcast-event integrations. |
The native Windows worker already includes the relay. Following the older WSL/manual OBS setup as well introduces a second workflow that is unnecessary for this path.
## Gaps and confusing behavior found
1. **Automatic selection does not mean automatic airtime.** The Go live action sets a saved `live` flag. The worker follows it. There is no match-driven rule here that starts when matches appear and ends after the last result. Leaving the flag on keeps the broadcast requested even when the director is idle.
2. **Go live does not provision a computer or container.** A worker must already be running. The Windows installer registers a scheduled task, but the website has no cloud provisioning path.
3. **On air is not proof of video delivery.** The Stream page's main label uses a recent broadcast-session heartbeat. The relay opens a session before a video source has successfully started. Worker status is more specific; verify `obsStreaming`, `relayReceiving`, the destination and the actual picture/audio.
4. **The Windows path is implemented, not proven on hardware.** The handoff records no completed Windows run. Tests use fake desktop/OBS services and cannot validate capture, audio, OCR clicks, real downloads, remembered osu! login, GPU encoding or reboot recovery.
5. **The desktop is part of the system.** This worker runs in a signed-in Windows desktop and uses OCR and mouse input to select a room. Keeping the PC powered on alone is insufficient: locked-desktop behavior, display availability and reboot sign-in need testing. The room-selection method also means the PC may be unsuitable for simultaneous personal use.
6. **One gameplay worker per PC.** The implementation enforces a host lock because tosu can read another instance's osu!tourney processes. Dev/staging/prod can have separate configurations, but they cannot all broadcast gameplay concurrently on this PC.
7. **Documentation mixes paths.** The root README sends Windows users to the manual WSL guide. The Broadcast introduction still describes adding an OBS browser source manually. The Go live action still returns a message telling the user to start Vash TV. The newer Windows section describes the intended automated path.
8. **Cloud is a future host change plus integration work.** A persistent Windows GPU VM is a plausible route for this worker; Windows-capable graphics instances exist, for example in [EC2's accelerated instance families](https://docs.aws.amazon.com/ec2/latest/instancetypes/ac.html). The current encoder configuration accepts only AMD or x264, so using an NVIDIA machine requires adding NVENC support. Desktop/capture behavior must also be verified there. No provider, price or instance size has been selected.
Source anchors: [director](../src/lib/broadcast/director.ts), [Go live action](<../src/routes/(app)/broadcast/[id]/stream/+page.server.ts>), [Stream page](<../src/routes/(app)/broadcast/[id]/stream/+page.svelte>), [worker](windows/worker.ts), [relay](relay.ts), [encoder configuration](windows/config.ts), [platform definitions](../src/lib/broadcast/platforms.ts).
## First test: understand the overlay without broadcasting
1. Open the dev site's dashboard with a user who can access the simulation lab and manage Broadcast channels.
2. In Simulation lab, select the simulated lobby and start **Bots vs bots → 1v1**.
3. Create a Broadcast channel following **One match**, select that match, and use **Vash only** for this browser-only test.
4. Open the channel's overlay link. Watch the lobby, picks/bans, scores, map results and series result. The match's **Sim lab** controls can slow it down or finish a map.
5. In **Edit layout**, check which components you actually want. Note unwanted features before removing or redesigning them.
No Go live action or OBS setup is needed to inspect the overlay. Simulated bots provide match data, not rendered osu! gameplay. Even in a real-lobby lab match, the simulated opponent has no gameplay client to capture.
## Second test: the native Windows worker
Use the [Windows setup instructions](README.md#3-full-tourney-broadcast-on-a-windows-pc). Choose one environment and one channel for the test, with **Over osu! gameplay**, **One match**, landscape and initially 720p30. This is a conservative test setting, not a measured hardware limit.
One-time prerequisites are a signed-in/unlocked Windows desktop, an existing osu! stable executable to copy, and a usable spectator login with active supporter. The [official osu!tourney setup guide](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup) requires supporter, a separate installation and a remembered login. Arrange the spectator account separately from the account playing the test match.
Build from the repository root, then transfer the executable to the Windows PC using an available connection:
```sh
bun run broadcast:build
```
In Windows PowerShell, from the folder containing the executable, replace the placeholders with the selected site's actual values:
```powershell
.\vash-broadcast.exe setup --instance alpha --server https://YOUR_VASH_SITE --channel CHANNEL_ID --token AGENT_TOKEN --no-autostart
.\vash-broadcast.exe login --instance alpha
.\vash-broadcast.exe doctor --instance alpha
.\vash-broadcast.exe start --instance alpha
.\vash-broadcast.exe status --instance alpha
.\vash-broadcast.exe snapshot --instance alpha
```
For dev/staging behind Access, add `--access-id ACCESS_CLIENT_ID --access-secret ACCESS_CLIENT_SECRET` to setup. Login prints the instructions for its temporary screen viewer over an SSH tunnel. It is the one-time interactive step; subsequent matches should require no manual OBS scene or room setup. `--no-autostart` keeps this test instance from starting at Windows sign-in.
Configure `OSU_TOURNEY_ACCOUNT` on the matching Vash server before creating the real lobby so the host grants the spectator referee access. Use a real player match for the capture test; two real sides are necessary to prove both gameplay views.
Before pressing Go live, verify which destinations are enabled. That button requests transmission to all enabled destinations. With destinations disabled and recording/storage configured, it still uploads video for the site's playback; this is not necessarily a private local recording. First inspect the worker's OBS snapshot while off air.
After deliberately enabling the desired test output, press Go live and check:
| Check | Passing evidence |
| --------------------- | ------------------------------------------------------------------------------------------ |
| Lobby selection | Correct lobby ID; `spectating`, with room confirmation in status |
| Picture | Gameplay beneath the overlay; correct players; control panel cropped out |
| Scores and scenes | tosu scores reach Vash; scenes change with the actual match |
| Audio | Gameplay audio present and in sync; no duplicate or unintended desktop/microphone audio |
| Encoding | Status/log reports AMD AMF hardware encoding, not a software fallback |
| Delivery | OBS streaming, relay receiving, and the destination actually plays moving video with sound |
| Recording, if enabled | Site playback works; ended recording includes the final map and result |
| Ending | End stream stops OBS output and ends the session |
For diagnostics:
```powershell
.\vash-broadcast.exe logs --instance alpha -f
.\vash-broadcast.exe status --instance alpha --json
.\vash-broadcast.exe snapshot --instance alpha
.\vash-broadcast.exe stop --instance alpha
```
End stream clears the site's request to broadcast. Stopping just the worker does not clear that request, so restarting it while the channel is still armed can resume transmission.
## Before calling it unattended
Test two consecutive matches with no manual room selection, a change in team size, an OBS/tosu restart, a temporary network interruption, Windows restart/sign-in, and a run lasting as long as a real event. Observe frame drops, audio sync, memory, destination reconnects and recording continuity. These are acceptance checks still to run, not results already obtained.
Separately decide whether airtime should follow match activity automatically or remain continuously enabled after one Go live action. The current implementation supports the latter behavior; the former needs an explicit policy and implementation.
## Verification performed in this review
- `bun run test:streamer`: **38 passed**, 0 failed, after allowing local test-server sockets outside the sandbox.
- `bun --bun vitest run src/lib/broadcast/director.test.ts src/lib/broadcast/worker.test.ts`: **7 passed**, 0 failed, outside the sandbox. The sandboxed attempt stalled and was stopped.
- `bun run broadcast:build`: **passed**, producing `dist/vash-broadcast.exe`.
- No Windows host run, real gameplay stream, live social-platform transmission, cloud provisioning or long-duration test was performed in this review.
Application behavior was not changed by this review. Existing uncommitted implementation work was preserved.

View File

@@ -1,14 +1,14 @@
# Streaming Vash matches
For a first test on Windows with Ubuntu WSL, follow [the WSL setup guide](WSL.md).
For a broadcast from a Windows PC with real osu!tourney gameplay, use `vash-broadcast` ([section 3](#3-full-tourney-broadcast-on-a-windows-pc)): one program that sets everything up once and then runs unattended.
Vash can put its matches on stream by itself. There are three ways to use it, from no PC at all to a full tournament broadcast.
| Setup | What viewers see | Needs |
| -------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| **Vash TV** (headless container) | Vash's own data: scoreboard, mappool with picks and bans, live scores, predictions, chat, ticker | A small cloud VM or any Docker host |
| **Overlay in OBS** | Your OBS scenes with the Vash overlay on top | OBS |
| **Full tourney broadcast** | Real osu!tourney gameplay under the overlay, live scores, and the same multistream and recording | Dedicated Docker host with Wine, osu!, and tosu, or a Windows PC with OBS |
| Setup | What viewers see | Needs |
| -------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| **Vash TV** (headless container) | Vash's own data: scoreboard, mappool with picks and bans, live scores, predictions, chat, ticker | A small cloud VM or any Docker host |
| **Overlay in OBS** | Your OBS scenes with the Vash overlay on top | OBS |
| **Full tourney broadcast** | Real osu!tourney gameplay under the overlay, live scores, and the same multistream and recording | A Windows PC running `vash-broadcast`, or a Docker host with Wine |
```
Vash match runtime ─▶ director ─▶ channel websocket ─▶ tourney agent
@@ -64,7 +64,124 @@ Saving updates every open overlay immediately, including the one in OBS.
Add a **Browser** source with the overlay link, 1920×1080, on top of your scene. That is all. To put gameplay under it, capture anything below the browser source; the gameplay scene of the _Tourney client_ preset leaves that area see-through.
## 3. Full tourney broadcast (Windows)
## 3. Full tourney broadcast on a Windows PC
`vash-broadcast.exe` sets up and runs the whole broadcast on a Windows PC. Setup is once per instance; after that there is nothing to do per stream. **Go live** and **End stream** on the channel's Stream page start and stop it, and each match's room is selected without anyone touching the PC. Everything is done from an SSH session.
```
Vash ──follow (match, lobby, team size)──▶ worker ──▶ osu!tourney: tournament.cfg, restart, click the room
▲ │ │ window
│ live scores, worker status │ ▼
└──────────────── tosu ◀── reads ──────────┘ OBS (portable, this instance only)
overlay on top of the captured window
Go live ──▶ worker ──▶ local relay (ffmpeg) ◀── RTMP ── AMD AMF hardware encoder
├──▶ Twitch / YouTube / other RTMP
└──▶ Vash live video and recording (HLS to storage)
```
### Before you start
- **Windows 10 or 11, signed in at the PC.** OBS, osu! and window capture need the signed-in desktop, so the worker runs there (through a scheduled task) and not in the SSH session. It keeps running when SSH disconnects. It cannot run before someone signs in: for unattended reboots, turn on automatic sign-in (for example with Sysinternals Autologon) and turn off **Require sign-in** after sleep. Room selection clicks in osu!tourney, so the desktop must be **unlocked**; the worker keeps the PC awake and the display on while it runs, and says `desktop-locked` if Windows locks anyway.
- **osu! stable installed.** Setup copies its `osu!.exe` into a separate folder, as the [osu!tourney setup guide](https://osu.ppy.sh/wiki/en/osu!_tournament_client/osu!tourney/Setup) says; your own installation is only read.
- **A spectator osu! account with osu!supporter** (osu!tourney requires it), separate from your playing account. Set `OSU_TOURNEY_ACCOUNT` to its username in each Vash server's environment so new lobbies add it as a referee.
- **The program.** Build it with `bun run broadcast:build` (it cross-compiles from Linux) and copy `dist/vash-broadcast.exe` to the PC, e.g. `scp dist/vash-broadcast.exe stan@stan-win11:`.
- **The channel's agent token** from **Agent token** on the Broadcast page. The page shows the exact setup command.
- **Dev and staging sites behind Cloudflare Access** need a service token: Zero Trust → Access → Service Auth → create one, and add a _Service Auth_ policy for it to the site's Access application. Production needs none.
### Set up an instance
```powershell
.\vash-broadcast.exe setup --instance prod --server https://esports.vash.software --channel <channel id> --token <agent token>
.\vash-broadcast.exe setup --instance dev --server https://esports-dev.vash.software --channel <id> --token <token> --access-id <id> --access-secret <secret>
```
Setup checks the site and token first, then installs into `%LOCALAPPDATA%\Vash\Broadcast\instances\<instance>`:
| What | Version | Notes |
| ---------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OBS Studio, portable | 32.2.2 | Its own settings, scene, websocket port and password. Your own OBS is untouched. Profile: the channel's size, frame rate and bitrate, AMD AMF hardware encoding, streaming to the local relay. |
| tosu | 4.26.2 | Own port, no auto-update, no browser on start. |
| FFmpeg (gyan.dev essentials) | 9.0.2 | The local relay: restreams and records what OBS sends, unchanged. |
| osu!tourney folder | copied `osu!.exe` | Installs itself on first run during the sign-in. |
Every download is pinned and its SHA-256 checked before use (`streamer/windows/install.ts`); nothing is downloaded when the worker starts. Setup also copies itself to `%LOCALAPPDATA%\Vash\Broadcast\bin`, and registers the scheduled task _Vash Broadcast (prod)_ that runs the worker in your desktop when you sign in (`--no-autostart` to only start it by hand). Running setup again keeps the settings, login and scene, and only changes what differs; stop the instance first. Other options: `--osu-exe`, `--tourney-height` (default 720; the window is about 200 px taller), `--encoder x264` to choose software encoding on purpose, `--token-stdin`.
### Sign in the spectator account, once
```powershell
.\vash-broadcast.exe login --instance prod
```
This opens osu! normally on the PC's screen and prints an SSH tunnel command and a link. Open the tunnel from your Mac and the link in a browser: it shows the PC's screen and passes your clicks and typing through. A fresh installation first downloads osu!'s files. Sign in, tick **Remember username** and **Remember password**, and press **Finish**. osu! closes, and the worker checks the login was saved. The viewer listens on 127.0.0.1 only, needs the printed token, and exists only during the sign-in. Other instances can reuse it: `login --instance dev --copy-from prod`.
### Start it, then use the site
```powershell
.\vash-broadcast.exe start --instance prod
```
The worker starts OBS, tosu and osu!tourney, and follows the channel:
- **Matches.** When the director picks a match, osu!tourney restarts if the team size needs it, and the worker selects the match's room (see below). The captured gameplay is shown under the overlay only once tosu confirms the right room; otherwise the overlay's own scenes cover it.
- **Go live** makes the relay listen, then OBS streams into it; the relay sends to every enabled destination and records for the site. **End stream** stops OBS, finishes the recording and ends the session.
- **Failures.** OBS, tosu, osu!tourney and the relay restart on their own with growing delays; so does the worker itself. Channel video changes restart OBS while off air.
- **Status.** The worker reports its state to the site (Broadcast list and Stream page) and to `status`:
| State | Meaning |
| -------------------------- | ----------------------------------------------------------------------------- |
| `starting`, `reconnecting` | Starting or restarting a program; the detail says which. |
| `idle` | Ready; the channel shows no match with an osu! lobby. |
| `waiting-for-lobby` | The room is not in osu!tourney's list yet. |
| `selecting` | Clicked the room; waiting for tosu to confirm it. |
| `spectating` | tosu confirms the room. (If tosu is down, the detail says it is unconfirmed.) |
| `login-required` | Run `login` once. |
| `desktop-locked` | Windows is locked; the room cannot be clicked. |
| `blocked` | Another instance is running osu!tourney on this PC. |
| `error` | Needs attention; the detail says why. |
### How the room is selected
osu!tourney has no command or IPC for choosing a room. It lists rooms named `VASH: (Red) vs (Blue)` in its control panel as `<match id> : <room name>`. The worker brings the osu!tourney window forward, reads the list with Windows' built-in OCR (no extra install), clicks the line with the lobby's match id, and then checks tosu: osu!tourney's current team names must be the requested room's. Without a readable match id it clicks a room by name only when exactly one room has both team names; it never clicks "the first room". It retries while the room is not listed, and selects again if tosu shows another room. The last capture of the panel and what OCR read are in the instance's `snapshots` folder.
### Several instances (dev, staging, prod)
Each instance has its own folder, OBS, osu!tourney folder and login, tosu, relay, ports, logs and scheduled task, and stopping one only ever stops programs inside its own folder. One limit is real: tosu reads every osu! process on the PC and uses the first osu!tourney it finds, so **only one instance runs osu!tourney at a time**. Another started instance waits in `blocked` until the first stops. Switch with `stop --instance dev` and `start --instance prod`.
### Checking on it
Live score collection works while the worker is **off air**, too: it only needs tosu to read the
match in osu!tourney and the channel to follow that match. Go live is not required. The match page
shows each player's score, accuracy and combo during play, and marks scores as delayed when no
fresh update arrives for five seconds. A simulated opponent has no real gameplay client for tosu
to read; its scores come from Vash's simulator instead.
If the match page is waiting for scores, `status --instance prod` shows `tosu`'s player count and
last frame time, followed by `scores`' last send time, accepted player count and last acceptance
time. These distinguish a missing tosu feed from a feed Vash cannot associate with the current
map or roster. Only the channel's followed match receives that worker's scores.
After replacing the executable with a newer build, stop the instance, run `setup --instance prod`
again to update its installed worker copy using the saved settings, then start it. Use your own
instance name in place of `prod`.
```powershell
.\vash-broadcast.exe status --instance prod # state, match, room, OBS, relay, destinations, encoder
.\vash-broadcast.exe logs --instance prod -f
.\vash-broadcast.exe snapshot --instance prod # PNGs of OBS's output and the osu!tourney window
.\vash-broadcast.exe doctor --instance prod # setup, task, login, GPU; desktop, OCR and capture checks
.\vash-broadcast.exe stop --instance prod # goes off air cleanly first
.\vash-broadcast.exe list
```
`status` shows which encoder OBS actually runs, read from OBS's log after the stream starts. When the AMD encoder is unavailable, OBS switches to x264 by itself; the worker then warns rather than claiming hardware encoding.
### What has been tested
The logic is covered by `bun run test:streamer`. This includes room matching, OCR line handling and tosu confirmation against a fake desktop, launch and restart rules, OBS scene reconciliation and websocket timeouts against a fake OBS, instance isolation, the tourney lock, supervision backoff, pinned downloads, the relay lifecycle, and the worker's start, report, Go live and stop against a fake Vash. **None of it has run on Windows yet.** Still to confirm on the PC: the PowerShell desktop helper (window discovery, focus, clicks, capture, Windows OCR of the room list), osu!tourney's window layout and title handling, OBS window capture with its audio, AMD AMF output, the scheduled task start from SSH, the sign-in viewer, and a multi-hour soak with real matches. Run `doctor`, then a test match, and compare `snapshot` output.
### Manual OBS setup
Before `vash-broadcast`, the Windows setup was assembled by hand. It still works when you want to run OBS yourself; nothing below is automatic.
Live scores for real lobbies only come from the osu! tourney client. Bancho's IRC only reports results once a map ends. So the streaming PC runs:
@@ -123,7 +240,7 @@ For playback in Chrome and Firefox, the bucket needs a CORS rule (Cloudflare das
The `streamer/tv` image renders a channel in Chromium on a virtual screen, encodes it once, and copies the stream to every destination and to the recording. It follows the channel's **Go live** switch, so the container can idle between matches. To use your own PC for now, build and run it there:
```sh
docker build -t vash-tv streamer/tv
docker build -f streamer/tv/Dockerfile -t vash-tv streamer
docker run -d --restart unless-stopped --shm-size=1g --name vash-tv \
-e VASH_SERVER=https://esports.vash.software \
-e VASH_CHANNEL=<channel id> \

View File

@@ -77,7 +77,7 @@ Install Docker Desktop on Windows. Enable **Use the WSL 2 based engine** and **S
```bash
docker version
docker build -t vash-tv streamer/tv
docker build -f streamer/tv/Dockerfile -t vash-tv streamer
docker run -d --name vash-relay --restart unless-stopped \
-p 127.0.0.1:1935:1935 \
-e VASH_SERVER=https://esports-dev.vash.software \

View File

@@ -15,8 +15,9 @@
* Build single binaries with `bun run streamer:build`.
*/
import { parseArgs } from 'node:util';
import { VashLink, type Follow } from './link';
import { Obs } from './obs';
import { parseTosu, type TosuFrame } from './tosu';
import { TosuFeed } from './tosu';
import { Tourney } from './tourney';
const { values: args } = parseArgs({
@@ -116,8 +117,6 @@ async function connectObs() {
// ── Vash ────────────────────────────────────────────────────────────────
let vash: WebSocket | null = null;
let vashAttempts = 0;
let current: { scene: string; matchId: string | null; lobbyId: number | null } = {
scene: 'idle',
matchId: null,
@@ -135,38 +134,16 @@ let teamSize = 1;
let selectedLobby: number | null = null;
let lastJoinNotice = 0;
function connectVash() {
const url = `${server.replace(/^http/, 'ws')}/ws/agent`;
const socket = new WebSocket(url);
vash = socket;
socket.onopen = () =>
socket.send(JSON.stringify({ type: 'hello', channelId: channel, token: args.token }));
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (message.type === 'welcome') {
vashAttempts = 0;
say(`Connected to ${server} as channel "${message.channel}". Overlay: ${overlayUrl}`);
} else if (message.type === 'follow') void follow(message);
};
socket.onclose = (event) => {
if (event.code === 4001) {
say('Vash refused the channel or token. Check them on the Broadcast page.');
process.exit(1);
}
const wait = Math.min(30_000, 1000 * 2 ** vashAttempts++);
say(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
setTimeout(connectVash, wait);
};
}
const link = new VashLink({
server,
channel,
token: args.token!,
log: say,
onFollow: (next) => void follow(next),
onRejected: () => process.exit(1)
});
async function follow(next: {
scene: string;
matchId: string | null;
lobbyId: number | null;
title: string | null;
roomName?: string | null;
teamSize?: number | null;
}) {
async function follow(next: Follow) {
const changedMatch = next.matchId !== current.matchId;
const changedLobby = next.lobbyId != null && next.lobbyId !== current.lobbyId;
if (changedMatch)
@@ -217,48 +194,8 @@ if (tourney && args['auto-join']) {
// ── tosu ────────────────────────────────────────────────────────────────
let tosuAttempts = 0;
let lastSent = 0;
let lastKey = '';
let pending: TosuFrame | null = null;
const tosu = new TosuFeed(args.tosu!, (frame) => link.sendScores(frame), say);
function connectTosu() {
const socket = new WebSocket(args.tosu!);
socket.onopen = () => {
tosuAttempts = 0;
say('tosu connected');
};
socket.onmessage = (event) => {
const frame = parseTosu(JSON.parse(String(event.data)));
if (!frame || !frame.clients.length) return;
pending = frame;
flush();
};
socket.onclose = () => {
// tosu is often started after the agent; keep trying quietly.
const wait = Math.min(15_000, 1000 * 2 ** tosuAttempts++);
if (tosuAttempts === 3)
say(`tosu is not running at ${args.tosu}; live scores will start when it is.`);
setTimeout(connectTosu, wait);
};
}
/** At most four updates a second, and only when something changed. */
function flush() {
if (!pending || vash?.readyState !== WebSocket.OPEN) return;
const wait = 250 - (Date.now() - lastSent);
if (wait > 0) {
setTimeout(flush, wait);
return;
}
const frame = pending;
pending = null;
const key = frame.clients.map((client) => `${client.osuId}:${client.score}`).join('|');
if (key === lastKey) return;
lastKey = key;
lastSent = Date.now();
vash.send(JSON.stringify({ type: 'tosu', progress: frame.progress, clients: frame.clients }));
}
connectVash();
connectTosu();
say(`Overlay: ${overlayUrl}`);
link.start();
tosu.start();

283
streamer/link.test.ts Normal file
View File

@@ -0,0 +1,283 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Server } from 'bun';
import { VashLink, type Follow } from './link';
import { Broadcaster, parsePlaylist, type Source } from './relay';
import type { StreamConfig, VashApi } from './vash-api';
import { startAccessProxy, withoutReferrer } from './windows/proxy';
const servers: { stop(force?: boolean): unknown }[] = [];
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
});
const until = async (check: () => boolean, ms = 2000) => {
for (let waited = 0; waited < ms && !check(); waited += 10) await Bun.sleep(10);
expect(check()).toBe(true);
};
describe('VashLink', () => {
function fakeVash(token: string) {
const received: Record<string, unknown>[] = [];
const server: Server<undefined> = Bun.serve({
port: 0,
fetch: (request, server) => (server.upgrade(request) ? undefined : new Response('no')),
websocket: {
open(ws) {
ws.subscribe('agents');
},
message(ws, raw) {
const message = JSON.parse(String(raw));
received.push(message);
if (message.type === 'tosu')
ws.send(
JSON.stringify({
type: 'scores',
matchId: message.matchId,
accepted: message.clients.length
})
);
if (message.type !== 'hello') return;
if (message.token !== token) return ws.close(4001, 'Unknown channel or wrong token');
ws.send(JSON.stringify({ type: 'welcome', channel: 'Main' }));
ws.send(
JSON.stringify({
type: 'follow',
scene: 'gameplay',
matchId: 'm1',
lobbyId: 118000042,
roomName: 'VASH: (A) vs (B)',
teamSize: 2,
title: 'A vs B'
})
);
}
}
});
servers.push(server);
return { server, received, url: `http://127.0.0.1:${server.port}` };
}
test('signs in, follows, throttles scores and reports status only when it changes', async () => {
const vash = fakeVash('good');
const follows: Follow[] = [];
const link = new VashLink({
server: vash.url,
channel: 'c',
token: 'good',
onFollow: (follow) => follows.push(follow)
});
link.start();
await until(() => link.connected && follows.length === 1);
expect(follows[0]).toEqual({
scene: 'gameplay',
matchId: 'm1',
lobbyId: 118000042,
roomName: 'VASH: (A) vs (B)',
teamSize: 2,
title: 'A vs B'
});
const frame = (score: number) => ({
ipcState: 3,
beatmapId: 1,
progress: 0.5,
teams: { left: 'A', right: 'B' },
clients: [{ osuId: 2, name: 'p', score, accuracy: 99, combo: 1, failed: false }]
});
for (let score = 1; score <= 10; score++) link.sendScores(frame(score));
await Bun.sleep(300);
const scores = vash.received.filter((message) => message.type === 'tosu');
expect(scores.length).toBeLessThanOrEqual(2);
expect((scores.at(-1)!.clients as { score: number }[])[0].score).toBe(10);
const status = {
state: 'spectating',
detail: null,
onAir: true,
encoder: 'AMD AMF (hardware)',
instance: 'prod',
version: '0.1.0'
};
link.report(status);
link.report(status);
link.report({ ...status, state: 'selecting' });
await Bun.sleep(50);
expect(
vash.received.filter((message) => message.type === 'status').map((message) => message.state)
).toEqual(['spectating', 'selecting']);
link.stop();
});
test('sends accuracy, combo and progress changes, heartbeats fresh scores, and drops queued scores on a director cut', async () => {
const vash = fakeVash('good');
const link = new VashLink({ server: vash.url, channel: 'c', token: 'good' });
link.start();
try {
await until(() => link.follow?.matchId === 'm1');
const frame = {
ipcState: 3,
beatmapId: 75,
progress: 0.1,
teams: { left: 'A', right: 'B' },
clients: [{ osuId: 2, name: 'p', score: 100, accuracy: 99, combo: 10, failed: false }]
};
const messages = () => vash.received.filter((message) => message.type === 'tosu');
link.sendScores(frame);
await until(() => link.scores.players === 1);
expect(messages()[0]).toMatchObject({ matchId: 'm1', beatmapId: 75 });
expect(link.scores.acceptedAt).toBeGreaterThan(0);
const changed = {
...frame,
progress: 0.2,
clients: [{ ...frame.clients[0], accuracy: 97, combo: 0 }]
};
link.sendScores(changed);
await until(() => messages().length === 2);
expect(messages()[1]).toMatchObject({
progress: 0.2,
clients: [{ score: 100, accuracy: 97, combo: 0 }]
});
await Bun.sleep(1050);
link.sendScores(changed);
await until(() => messages().length === 3);
link.sendScores({ ...changed, progress: 0.3 });
vash.server.publish(
'agents',
JSON.stringify({ type: 'follow', matchId: 'm2', lobbyId: 2, scene: 'gameplay' })
);
await until(() => link.follow?.matchId === 'm2');
await Bun.sleep(300);
expect(messages()).toHaveLength(3);
expect(link.scores.players).toBe(0);
link.sendScores(changed);
await until(() => messages().length === 4);
expect(messages()[3]).toMatchObject({ matchId: 'm2', beatmapId: 75 });
} finally {
link.stop();
}
});
test('a refused token is reported, not retried in a tight loop', async () => {
const vash = fakeVash('good');
let rejected = 0;
const link = new VashLink({
server: vash.url,
channel: 'c',
token: 'bad',
onRejected: () => rejected++
});
link.start();
await until(() => rejected === 1);
expect(link.rejected).toBe(true);
await Bun.sleep(200);
expect(vash.received.filter((message) => message.type === 'hello')).toHaveLength(1);
link.stop();
});
});
describe('Broadcaster', () => {
class FakeSource implements Source {
starts = 0;
stops = 0;
running = false;
start() {
this.starts++;
this.running = true;
}
stop() {
this.stops++;
this.running = false;
}
}
test('follows Go live: one session per broadcast, restarting a dropped source', async () => {
const reports: Record<string, unknown>[] = [];
const api = {
report: async (body: Record<string, unknown>) => {
reports.push(body);
return body.event === 'start' ? { id: 's1', storagePrefix: null } : { ok: true };
}
} as unknown as VashApi;
const source = new FakeSource();
const broadcaster = new Broadcaster({
api,
ffmpeg: 'ffmpeg',
hlsDir: '/nonexistent',
source,
log: () => {}
});
const config: StreamConfig = {
live: true,
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
overlayUrl: '',
record: false,
destinations: []
};
await broadcaster.apply(config);
expect(broadcaster.onAir).toBe(true);
expect(reports).toEqual([{ event: 'start' }]);
await broadcaster.apply(config);
expect(source.starts).toBe(1);
source.running = false; // OBS dropped
await broadcaster.apply(config);
expect(source.starts).toBe(2);
await broadcaster.apply({ ...config, live: false });
expect(broadcaster.onAir).toBe(false);
expect(reports.at(-1)).toMatchObject({ event: 'end', sessionId: 's1' });
expect(source.running).toBe(false);
}, 10_000);
test('reads HLS playlists with CRLF line endings', () => {
expect(
parsePlaylist('#EXTM3U\r\n#EXTINF:2.000,\r\nseg_00001.ts\r\n#EXTINF:1.5,\r\nseg_00002.ts\r\n')
).toEqual([
{ name: 'seg_00001.ts', duration: 2 },
{ name: 'seg_00002.ts', duration: 1.5 }
]);
});
});
describe('Access proxy', () => {
test('adds the service token to page loads and websockets, and drops the referrer', async () => {
const seen: string[] = [];
const upstream: Server<undefined> = Bun.serve({
port: 0,
fetch(request, server) {
seen.push(request.headers.get('cf-access-client-id') ?? 'none');
if (request.headers.get('upgrade'))
return server.upgrade(request) ? undefined : new Response('no');
return new Response('<html><head><title>x</title></head></html>', {
headers: { 'content-type': 'text/html' }
});
},
websocket: {
message: (ws, message) => {
ws.send(`echo ${message}`);
}
}
});
servers.push(upstream);
const proxy = startAccessProxy({
port: 0,
upstream: `http://127.0.0.1:${upstream.port}`,
headers: { 'CF-Access-Client-Id': 'id', 'CF-Access-Client-Secret': 'secret' },
log: () => {}
});
servers.push(proxy);
const html = await (await fetch(`http://127.0.0.1:${proxy.port}/broadcast/x/overlay`)).text();
expect(html).toBe(
'<html><head><meta name="referrer" content="no-referrer"><title>x</title></head></html>'
);
const socket = new WebSocket(`ws://127.0.0.1:${proxy.port}/ws`);
const reply = await new Promise<string>((resolve) => {
socket.onopen = () => socket.send('hi');
socket.onmessage = (event) => resolve(String(event.data));
});
socket.close();
expect(reply).toBe('echo hi');
expect(seen).toEqual(['id', 'id']);
expect(withoutReferrer('<p>no head</p>')).toStartWith('<meta name="referrer"');
});
});

196
streamer/link.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* The agent connection to a Vash broadcast channel (/ws/agent): Vash says
* which match, lobby and scene to follow; we send live tosu scores and,
* from an unattended worker, what the broadcast PC is doing.
*/
import type { TosuFrame } from './tosu';
export interface Follow {
scene: string;
matchId: string | null;
lobbyId: number | null;
roomName: string | null;
teamSize: number | null;
title: string | null;
}
/** What an unattended worker reports; shown on the channel's pages. */
export interface WorkerReport {
state: string;
detail: string | null;
onAir: boolean;
encoder: string | null;
instance: string | null;
version: string | null;
}
export interface LinkOptions {
server: string;
channel: string;
token: string;
/** Extra headers, e.g. Cloudflare Access service-token headers. */
headers?: Record<string, string>;
log?: (...parts: unknown[]) => void;
onFollow?: (follow: Follow) => void;
/** The channel or token was refused; retrying will not help until it changes. */
onRejected?: () => void;
}
export class VashLink {
connected = false;
rejected = false;
channelName: string | null = null;
follow: Follow | null = null;
private socket: WebSocket | null = null;
private attempts = 0;
private stopped = false;
private timer: ReturnType<typeof setTimeout> | null = null;
private lastSent = 0;
private lastKey = '';
private pending: TosuFrame | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private lastReport = '';
private lastReportAt = 0;
/** Server acknowledgement: a connected socket alone does not prove scores reached the match. */
scores = { sentAt: 0, acceptedAt: 0, players: 0 };
private log: (...parts: unknown[]) => void;
constructor(private options: LinkOptions) {
this.log = options.log ?? (() => {});
}
get url() {
return `${this.options.server.replace(/\/$/, '').replace(/^http/, 'ws')}/ws/agent`;
}
start() {
this.stopped = false;
this.rejected = false;
this.connect();
}
private connect() {
if (this.stopped) return;
const headers = this.options.headers ?? {};
// Bun's WebSocket accepts headers; they carry Cloudflare Access service tokens.
const socket = Object.keys(headers).length
? new WebSocket(this.url, { headers } as unknown as string[])
: new WebSocket(this.url);
this.socket = socket;
socket.onopen = () =>
socket.send(
JSON.stringify({
type: 'hello',
channelId: this.options.channel,
token: this.options.token
})
);
socket.onmessage = (event) => {
let message: { type?: string; channel?: string; accepted?: number } & Partial<Follow>;
try {
message = JSON.parse(String(event.data));
} catch {
return;
}
if (message.type === 'welcome') {
this.attempts = 0;
this.connected = true;
this.channelName = message.channel ?? null;
this.lastReport = '';
this.lastKey = '';
this.log(`Connected to ${this.options.server} as channel "${message.channel}"`);
} else if (message.type === 'follow') {
const follow: Follow = {
scene: String(message.scene ?? 'idle'),
matchId: message.matchId ?? null,
lobbyId: typeof message.lobbyId === 'number' ? message.lobbyId : null,
roomName: message.roomName ?? null,
teamSize: typeof message.teamSize === 'number' ? message.teamSize : null,
title: message.title ?? null
};
if (follow.matchId !== this.follow?.matchId || follow.lobbyId !== this.follow?.lobbyId) {
this.pending = null;
this.lastKey = '';
this.scores = { sentAt: 0, acceptedAt: 0, players: 0 };
}
this.follow = follow;
this.options.onFollow?.(follow);
} else if (message.type === 'scores' && message.matchId === this.follow?.matchId) {
this.scores.players = Number(message.accepted) || 0;
if (this.scores.players > 0) this.scores.acceptedAt = Date.now();
}
};
socket.onclose = (event) => {
if (this.socket !== socket) return;
this.connected = false;
this.socket = null;
this.pending = null;
this.follow = null;
if (this.stopped) return;
if (event.code === 4001) {
this.rejected = true;
this.log('Vash refused the channel or token. Check them on the Broadcast page.');
this.options.onRejected?.();
// A rotated token is fixed by re-running setup; check again now and then anyway.
this.timer = setTimeout(() => this.connect(), 5 * 60_000);
return;
}
const wait = Math.min(30_000, 1000 * 2 ** this.attempts++);
this.log(`Lost Vash (${event.code}); retrying in ${Math.round(wait / 1000)}s`);
this.timer = setTimeout(() => this.connect(), wait);
};
}
/** At most four updates a second; unchanged fresh frames heartbeat once a second. */
sendScores(frame: TosuFrame) {
if (!frame.clients.length || !this.connected || !this.follow?.matchId) return;
this.pending = frame;
this.flush();
}
private flush() {
if (!this.pending || !this.connected || this.socket?.readyState !== WebSocket.OPEN) return;
const wait = 250 - (Date.now() - this.lastSent);
if (wait > 0) {
this.flushTimer ??= setTimeout(() => {
this.flushTimer = null;
this.flush();
}, wait);
return;
}
const frame = this.pending;
this.pending = null;
const message = JSON.stringify({
type: 'tosu',
matchId: this.follow?.matchId,
beatmapId: frame.beatmapId && frame.beatmapId > 0 ? frame.beatmapId : null,
progress: frame.progress,
clients: frame.clients
});
if (message === this.lastKey && Date.now() - this.lastSent < 1000) return;
this.lastKey = message;
this.lastSent = Date.now();
this.scores.sentAt = this.lastSent;
this.socket.send(message);
}
/** Sends the worker's state when it changes, and every 30 seconds as a heartbeat. */
report(status: WorkerReport) {
if (!this.connected || this.socket?.readyState !== WebSocket.OPEN) return;
const key = JSON.stringify(status);
if (key === this.lastReport && Date.now() - this.lastReportAt < 30_000) return;
this.lastReport = key;
this.lastReportAt = Date.now();
this.socket.send(JSON.stringify({ type: 'status', ...status }));
}
stop() {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
if (this.flushTimer) clearTimeout(this.flushTimer);
const socket = this.socket;
this.socket = null;
this.connected = false;
socket?.close();
}
}

View File

@@ -17,6 +17,7 @@ test('tosu tourney frames become per-player scores with map progress', () => {
beatmap: { id: 75, time: { live: 30_000, firstObject: 10_000, lastObject: 50_000 } },
tourney: {
ipcState: 3,
team: { left: 'Alpha ', right: 'Beta' },
clients: [
{
user: { id: 2, name: 'peppy' },
@@ -30,9 +31,27 @@ test('tosu tourney frames become per-player scores with map progress', () => {
ipcState: 3,
beatmapId: 75,
progress: 0.5,
teams: { left: 'Alpha', right: 'Beta' },
clients: [
{ osuId: 2, name: 'peppy', score: 123_456, accuracy: 98.5, combo: 321, failed: false }
]
});
expect(parseTosu({ beatmap: {} })).toBeNull();
});
test('tosu keeps a named spectator while its profile id is not available', () => {
const frame = parseTosu({
tourney: {
clients: [
{
user: { id: 0, name: '' },
play: { playerName: 'Ocean Player0', score: 123, accuracy: 98, combo: { current: 5 } }
},
{ user: { id: 0, name: '' }, play: { score: 0 } }
]
}
});
expect(frame?.clients).toEqual([
{ osuId: 0, name: 'Ocean Player0', score: 123, accuracy: 98, combo: 5, failed: false }
]);
});

View File

@@ -1,6 +1,9 @@
/**
* Just enough of obs-websocket 5 (built into OBS 28+) to switch scenes and
* set up sources: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
* Just enough of obs-websocket 5 (built into OBS 28+) to set up and drive a
* broadcast: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
*
* `connected` means identified (authenticated), not merely an open socket.
* Every connect and request has a timeout, so a hung OBS cannot stall a worker.
*/
import { createHash, randomUUID } from 'node:crypto';
@@ -11,35 +14,94 @@ export function obsAuthentication(password: string, salt: string, challenge: str
return sha256(sha256(password + salt) + challenge);
}
/** obs-websocket close codes worth explaining. */
const CLOSE_REASONS: Record<number, string> = {
4009: 'OBS rejected the websocket password',
4010: 'OBS does not support obs-websocket RPC version 1'
};
export class ObsRequestError extends Error {
constructor(
message: string,
readonly requestType: string,
readonly code: number | null
) {
super(message);
}
}
interface Waiter {
resolve(data: unknown): void;
reject(err: Error): void;
timer: ReturnType<typeof setTimeout>;
}
export class Obs {
private socket: WebSocket | null = null;
private pending = new Map<string, { resolve(data: unknown): void; reject(err: Error): void }>();
private pending = new Map<string, Waiter>();
private identified = false;
/** Called once when an identified connection closes. */
onClose: ((reason: string) => void) | null = null;
constructor(
private url: string,
private password: string
private password: string,
private timeoutMs = 15_000
) {}
get connected() {
return this.socket?.readyState === WebSocket.OPEN;
return this.identified && this.socket?.readyState === WebSocket.OPEN;
}
connect(): Promise<void> {
this.close();
// Each connection owns its requests, so a late close of an old socket cannot fail new ones.
const pending = new Map<string, Waiter>();
this.pending = pending;
return new Promise((resolve, reject) => {
let settled = false;
const fail = (message: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
socket.close();
} catch {
/* already closed */
}
reject(new Error(message));
};
const timer = setTimeout(
() => fail(`OBS at ${this.url} did not finish the websocket handshake in time`),
this.timeoutMs
);
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onerror = () =>
reject(
new Error(
`Could not reach OBS at ${this.url}. Is Tools → WebSocket Server Settings enabled?`
)
);
socket.onclose = () => {
for (const waiter of this.pending.values()) waiter.reject(new Error('OBS disconnected'));
this.pending.clear();
fail(`Could not reach OBS at ${this.url}. Is its WebSocket server enabled?`);
socket.onclose = (event) => {
const wasIdentified = this.identified && this.socket === socket;
if (this.socket === socket) {
this.identified = false;
this.socket = null;
}
const reason = CLOSE_REASONS[event.code] ?? `OBS closed the websocket (${event.code})`;
for (const waiter of pending.values()) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
pending.clear();
fail(reason);
if (wasIdentified) this.onClose?.(reason);
};
socket.onmessage = (event) => {
const { op, d } = JSON.parse(String(event.data));
let message: { op: number; d: Record<string, unknown> };
try {
message = JSON.parse(String(event.data));
} catch {
return;
}
const { op, d } = message;
if (op === 0) {
const auth = d.authentication as { salt: string; challenge: string } | undefined;
socket.send(
@@ -54,15 +116,27 @@ export class Obs {
}
})
);
} else if (op === 2) resolve();
else if (op === 7) {
const waiter = this.pending.get(d.requestId);
this.pending.delete(d.requestId);
if (d.requestStatus?.result) waiter?.resolve(d.responseData);
} else if (op === 2) {
if (settled) return;
settled = true;
clearTimeout(timer);
this.identified = true;
resolve();
} else if (op === 7) {
const requestId = d.requestId as string;
const waiter = pending.get(requestId);
if (!waiter) return;
pending.delete(requestId);
clearTimeout(waiter.timer);
const status = d.requestStatus as
{ result?: boolean; code?: number; comment?: string } | undefined;
if (status?.result) waiter.resolve(d.responseData ?? {});
else
waiter?.reject(
new Error(
`OBS ${d.requestType}: ${d.requestStatus?.comment ?? d.requestStatus?.code}`
waiter.reject(
new ObsRequestError(
`OBS ${d.requestType}: ${status?.comment ?? status?.code ?? 'failed'}`,
String(d.requestType),
status?.code ?? null
)
);
}
@@ -70,18 +144,29 @@ export class Obs {
});
}
request<T = unknown>(requestType: string, requestData: Record<string, unknown> = {}): Promise<T> {
request<T = unknown>(
requestType: string,
requestData: Record<string, unknown> = {},
timeoutMs = this.timeoutMs
): Promise<T> {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN)
return Promise.reject(new Error('OBS is not connected'));
if (!socket || !this.connected) return Promise.reject(new Error('OBS is not connected'));
const requestId = randomUUID();
const pending = this.pending;
return new Promise<T>((resolve, reject) => {
this.pending.set(requestId, { resolve: resolve as (data: unknown) => void, reject });
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error(`OBS ${requestType} timed out`));
}, timeoutMs);
pending.set(requestId, { resolve: resolve as (data: unknown) => void, reject, timer });
socket.send(JSON.stringify({ op: 6, d: { requestType, requestId, requestData } }));
});
}
close() {
this.socket?.close();
const socket = this.socket;
this.socket = null;
this.identified = false;
socket?.close();
}
}

550
streamer/relay.ts Normal file
View File

@@ -0,0 +1,550 @@
/**
* Going on and off air for one channel: a source produces one MPEG-TS
* stream; it is copied to one small ffmpeg per destination (Twitch,
* YouTube, …) so a failing destination reconnects without touching the
* others, and cut into 2-second HLS segments that are uploaded to storage:
* the live stream on the Vash site and the recording afterwards.
*
* Vash TV uses it with its own screen encoder; the Windows worker with an
* RTMP ingest that OBS streams into.
*/
import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { Subprocess } from 'bun';
import type { StreamConfig, VashApi } from './vash-api';
export type Log = (...parts: unknown[]) => void;
export interface OutputStatus {
id: string;
label: string;
state: Output['state'];
error: string | null;
}
/** One consumer of the encoded stream: a destination, or the recorder. */
export class Output {
state: 'starting' | 'live' | 'reconnecting' | 'error' = 'starting';
error: string | null = null;
private process: Subprocess<'pipe', 'ignore', 'pipe'> | null = null;
private since = 0;
private queue: Uint8Array[] = [];
private queued = 0;
private writing = false;
private attempts = 0;
private stopped = false;
constructor(
readonly id: string,
readonly label: string,
private args: string[],
private ffmpeg: string,
private log: Log,
/** The destination URL, to notice when its settings change. */
readonly url: string | null = null
) {
this.spawn();
}
private spawn() {
if (this.stopped) return;
const process = Bun.spawn(
[
this.ffmpeg,
'-hide_banner',
'-loglevel',
'error',
'-f',
'mpegts',
'-i',
'pipe:0',
...this.args
],
{ stdin: 'pipe', stdout: 'ignore', stderr: 'pipe', windowsHide: true }
);
this.process = process;
this.since = Date.now();
this.queue = [];
this.queued = 0;
void (async () => {
let last = '';
for await (const chunk of process.stderr)
last = new TextDecoder().decode(chunk).trim().split('\n').pop() || last;
const code = await process.exited;
if (this.stopped || this.process !== process) return;
this.error = last || `ffmpeg exited with ${code}`;
this.state = this.attempts > 3 ? 'error' : 'reconnecting';
const wait = Math.min(30_000, 2000 * 2 ** this.attempts++);
this.log(`${this.label}: ${this.error}; retrying in ${Math.round(wait / 1000)}s`);
setTimeout(() => this.spawn(), wait);
})();
}
/**
* Queues bytes in order. Dropping any would corrupt the MPEG-TS timestamps,
* so a destination that falls far behind is restarted instead.
*/
write(chunk: Uint8Array) {
const process = this.process;
if (!process || this.stopped) return;
if (this.state !== 'live' && Date.now() - this.since > 5000 && process.exitCode == null) {
this.state = 'live';
this.error = null;
this.attempts = 0;
}
this.queue.push(chunk);
this.queued += chunk.byteLength;
if (this.queued > 32 * 1024 * 1024) {
this.error = 'Destination too slow; restarting';
this.queue = [];
this.queued = 0;
process.kill();
return;
}
void this.drain(process);
}
private async drain(process: Subprocess<'pipe', 'ignore', 'pipe'>) {
if (this.writing) return;
this.writing = true;
try {
while (this.queue.length && this.process === process) {
const chunk = this.queue.shift()!;
this.queued -= chunk.byteLength;
process.stdin.write(chunk);
const flushed = process.stdin.flush();
if (flushed instanceof Promise) await flushed;
}
} catch {
// The process is exiting; its watcher restarts it.
} finally {
this.writing = false;
}
}
status(): OutputStatus {
return { id: this.id, label: this.label, state: this.state, error: this.error };
}
stop() {
this.stopped = true;
try {
this.process?.stdin.end();
} catch {
/* already closed */
}
const process = this.process;
setTimeout(() => process?.kill(), 3000);
}
}
/** Produces the one MPEG-TS stream every output copies. */
export interface Source {
/** Starts producing; each chunk goes to write. */
start(config: StreamConfig, write: (chunk: Uint8Array) => void): Promise<void> | void;
stop(): void;
readonly running: boolean;
}
/** Listens for one RTMP publisher (OBS) and passes its stream on unchanged. */
export class RtmpIngest implements Source {
private process: Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
/** When the current listener started; OBS should connect after it is up. */
listeningSince = 0;
/** When a publisher last sent data. */
receivedAt = 0;
constructor(
private options: { ffmpeg: string; host: string; port: number; key: string; log: Log }
) {}
get running() {
return !!this.process && this.process.exitCode == null && !this.process.killed;
}
/** Receiving data from OBS in the last few seconds. */
get receiving() {
return this.running && Date.now() - this.receivedAt < 5000;
}
get url() {
return `rtmp://${this.options.host}:${this.options.port}/live`;
}
start(_config: StreamConfig, write: (chunk: Uint8Array) => void) {
if (this.running) return;
const { ffmpeg, host, port, key, log } = this.options;
const process = Bun.spawn(
[
ffmpeg,
'-hide_banner',
'-loglevel',
'error',
'-listen',
'1',
'-i',
`rtmp://${host}:${port}/live/${key}`,
'-c',
'copy',
'-f',
'mpegts',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'pipe', windowsHide: true }
);
this.process = process;
this.listeningSince = Date.now();
log(`Waiting for OBS on ${this.url}`);
void (async () => {
for await (const chunk of process.stdout) {
this.receivedAt = Date.now();
write(chunk);
}
})();
void (async () => {
let last = '';
for await (const chunk of process.stderr)
last = new TextDecoder().decode(chunk).trim() || last;
const code = await process.exited;
// OBS stopped or dropped; the next apply() listens again.
if (this.process === process) {
this.process = null;
if (code !== 0 && last) log(`Ingest stopped (${code}): ${last}`);
}
})();
}
stop() {
const process = this.process;
this.process = null;
process?.kill();
}
}
interface Session {
id: string;
storagePrefix: string | null;
startedAt: number;
}
export interface BroadcasterOptions {
/** Null streams without Vash managing sessions (no recording). */
api: VashApi | null;
ffmpeg: string;
hlsDir: string;
source: Source;
log: Log;
}
/** Follows a channel's desired state: on or off air, destinations, recording. */
export class Broadcaster {
onAir = false;
session: Session | null = null;
private outputs = new Map<string, Output>();
private vod: { name: string; duration: number }[] = [];
private uploaded = new Set<string>();
private lastVodUpload = 0;
private posterDone = false;
private lastReport = 0;
private config: StreamConfig | null = null;
private busy = false;
constructor(private options: BroadcasterOptions) {}
private get log() {
return this.options.log;
}
/** Reconciles with the latest channel settings. Call every few seconds. */
async apply(config: StreamConfig) {
if (this.busy) return;
this.busy = true;
try {
this.config = config;
if (config.live && !this.onAir) await this.goLive(config);
else if (!config.live && this.onAir) await this.goOffAir();
else if (this.onAir) {
this.syncDestinations(config);
if (!this.options.source.running)
await this.startSource(config).catch((err) =>
this.log('The stream source did not start:', (err as Error).message)
);
}
} finally {
this.busy = false;
}
}
/** Uploads recording segments and reports destination states. Call every second. */
async tick() {
if (!this.onAir) return;
await this.pumpRecording().catch((err) =>
this.log('Recording upload failed:', (err as Error).message)
);
if (Date.now() - this.lastReport > 10_000) {
this.lastReport = Date.now();
await this.report();
}
}
destinations(): OutputStatus[] {
return [...this.outputs.values()]
.filter((output) => output.id !== 'recording')
.map((output) => output.status());
}
get recording() {
return this.outputs.has('recording');
}
private write = (chunk: Uint8Array) => {
for (const output of this.outputs.values()) output.write(chunk);
};
private async startSource(config: StreamConfig) {
await this.options.source.start(config, this.write);
}
private async goLive(config: StreamConfig) {
const { api, hlsDir, ffmpeg } = this.options;
if (api) {
const started = await api.report<{ id: string; storagePrefix: string | null }>({
event: 'start'
});
this.session = { ...started, startedAt: Date.now() };
this.vod.length = 0;
this.uploaded.clear();
this.posterDone = false;
}
if (config.record && this.session?.storagePrefix) {
await rm(hlsDir, { recursive: true, force: true });
await mkdir(hlsDir, { recursive: true });
this.outputs.set(
'recording',
new Output(
'recording',
'Recording',
[
'-c',
'copy',
'-f',
'hls',
'-hls_time',
'2',
'-hls_list_size',
'8',
'-hls_segment_filename',
join(hlsDir, 'seg_%05d.ts'),
join(hlsDir, 'index.m3u8')
],
ffmpeg,
this.log
)
);
}
this.syncDestinations(config);
// On air from here: the session exists, so a source that fails to start is
// retried by apply() rather than opening another session.
this.onAir = true;
await this.startSource(config).catch((err) =>
this.log('The stream source did not start:', (err as Error).message)
);
this.log(
`On air: ${config.destinations.map((each) => each.label).join(', ') || 'recording only'}`
);
}
private async goOffAir() {
this.onAir = false;
this.options.source.stop();
for (const output of this.outputs.values()) output.stop();
this.outputs.clear();
await Bun.sleep(2500);
await this.pumpRecording(true).catch((err) =>
this.log('Final upload failed:', (err as Error).message)
);
const { api } = this.options;
if (api && this.session) {
await api
.report({
event: 'end',
sessionId: this.session.id,
durationSec: Math.round((Date.now() - this.session.startedAt) / 1000)
})
.catch(() => {});
}
this.session = null;
this.log('Off air');
}
private syncDestinations(config: StreamConfig) {
const wanted = new Map(config.destinations.map((destination) => [destination.id, destination]));
for (const [id, output] of this.outputs) {
if (id === 'recording') continue;
const destination = wanted.get(id);
if (!destination || output.url !== destination.url) {
output.stop();
this.outputs.delete(id);
this.log(`${output.label}: ${destination ? 'settings changed, reconnecting' : 'removed'}`);
}
}
for (const destination of config.destinations) {
if (this.outputs.has(destination.id)) continue;
this.outputs.set(
destination.id,
new Output(
destination.id,
destination.label,
['-c', 'copy', '-f', 'flv', destination.url],
this.options.ffmpeg,
this.log,
destination.url
)
);
this.log(`${destination.label}: connecting`);
}
}
private async upload(
files: Record<string, { body: Uint8Array | string; type: string; cache?: string }>
) {
const { api } = this.options;
if (!api || !this.session?.storagePrefix) return;
const names = Object.keys(files);
const { urls } = await api.uploadUrls(this.session.id, names);
await Promise.all(
names.map(async (name) => {
const file = files[name];
const response = await fetch(urls[name], {
method: 'PUT',
body: file.body,
headers: {
'content-type': file.type,
...(file.cache ? { 'cache-control': file.cache } : {})
}
});
if (!response.ok) throw new Error(`upload ${name}: ${response.status}`);
})
);
}
private vodPlaylist(ended: boolean) {
const target = Math.ceil(Math.max(2, ...this.vod.map((each) => each.duration)));
return [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${target}`,
'#EXT-X-MEDIA-SEQUENCE:0',
`#EXT-X-PLAYLIST-TYPE:${ended ? 'VOD' : 'EVENT'}`,
...this.vod.flatMap((each) => [`#EXTINF:${each.duration.toFixed(3)},`, `live/${each.name}`]),
...(ended ? ['#EXT-X-ENDLIST'] : [])
].join('\n');
}
/** Uploads new segments, then the live playlist, and now and then the growing VOD playlist. */
private async pumpRecording(final = false) {
if (!this.session?.storagePrefix) return;
const { hlsDir, ffmpeg } = this.options;
let playlist: string;
try {
playlist = await readFile(join(hlsDir, 'index.m3u8'), 'utf8');
} catch {
return;
}
const entries = parsePlaylist(playlist);
const fresh = entries.filter((entry) => !this.uploaded.has(entry.name));
if (fresh.length) {
const files: Record<string, { body: Uint8Array; type: string }> = {};
for (const entry of fresh)
files[`live/${entry.name}`] = {
body: new Uint8Array(await readFile(join(hlsDir, entry.name))),
type: 'video/mp2t'
};
await this.upload(files);
for (const entry of fresh) {
this.uploaded.add(entry.name);
this.vod.push(entry);
}
await this.upload({
'live/index.m3u8': {
body: playlist,
type: 'application/vnd.apple.mpegurl',
cache: 'no-cache, max-age=1'
}
});
}
if (final || Date.now() - this.lastVodUpload > 30_000) {
this.lastVodUpload = Date.now();
await this.upload({
'vod.m3u8': {
body: this.vodPlaylist(final),
type: 'application/vnd.apple.mpegurl',
cache: final ? 'max-age=3600' : 'no-cache'
}
});
}
// Local segments that left the live playlist are safely stored; free the disk.
const live = new Set(entries.map((entry) => entry.name));
for (const name of await readdir(hlsDir))
if (name.endsWith('.ts') && this.uploaded.has(name) && !live.has(name))
await rm(join(hlsDir, name), { force: true });
const session = this.session;
if (!this.posterDone && session && Date.now() - session.startedAt > 15_000 && entries.length) {
this.posterDone = true;
const latest = entries[entries.length - 1].name;
const poster = join(hlsDir, 'poster.jpg');
const grab = Bun.spawn(
[
ffmpeg,
'-hide_banner',
'-loglevel',
'error',
'-y',
'-i',
join(hlsDir, latest),
'-frames:v',
'1',
'-vf',
'scale=640:-2',
poster
],
{ windowsHide: true }
);
if ((await grab.exited) === 0)
await this.upload({
'poster.jpg': { body: new Uint8Array(await readFile(poster)), type: 'image/jpeg' }
}).catch(() => {});
}
}
private async report() {
const { api } = this.options;
if (!api || !this.session) return;
await api
.report({
event: 'status',
sessionId: this.session.id,
durationSec: Math.round((Date.now() - this.session.startedAt) / 1000),
destinations: this.destinations()
})
.catch((err) => this.log('Status report failed:', (err as Error).message));
}
/** Goes off air (ending the session and finishing the recording) if on air. */
async stop() {
if (this.onAir) await this.goOffAir();
this.options.source.stop();
}
}
/** Segment names and durations from an HLS media playlist. */
export function parsePlaylist(playlist: string) {
const entries: { name: string; duration: number }[] = [];
const lines = playlist.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
const match = /^#EXTINF:([\d.]+)/.exec(lines[index]);
if (match && lines[index + 1]?.trim())
entries.push({ name: lines[index + 1].trim(), duration: Number(match[1]) });
}
return entries;
}

View File

@@ -1,7 +1,8 @@
/**
* tosu (https://github.com/tosuapp/tosu) reads the osu! tourney client's
* memory and serves it over a websocket. We need each spectated player's
* live score, and how far through the map they are.
* live score, how far through the map they are, and the team names of the
* room being spectated (to confirm the right room was selected).
*/
export interface TosuClientScore {
@@ -20,6 +21,8 @@ export interface TosuFrame {
beatmapId: number | null;
/** 0–1 through the map. */
progress: number | null;
/** Team names osu!tourney parsed from the spectated room's name; empty when none. */
teams: { left: string; right: string };
clients: TosuClientScore[];
}
@@ -28,9 +31,11 @@ export function parseTosu(message: unknown): TosuFrame | null {
const root = message as {
tourney?: {
ipcState?: number;
team?: { left?: string; right?: string };
clients?: {
user?: { id?: number; name?: string };
play?: {
playerName?: string;
score?: number;
accuracy?: number;
failed?: boolean;
@@ -53,11 +58,18 @@ export function parseTosu(message: unknown): TosuFrame | null {
span > 0 && time?.live != null
? Math.max(0, Math.min(1, (time.live - time.firstObject!) / span))
: null,
teams: {
left: String(root.tourney.team?.left ?? '').trim(),
right: String(root.tourney.team?.right ?? '').trim()
},
clients: root.tourney.clients
.filter((client) => (client.user?.id ?? 0) > 0)
.filter(
(client) =>
(client.user?.id ?? 0) > 0 || client.user?.name?.trim() || client.play?.playerName?.trim()
)
.map((client) => ({
osuId: client.user!.id!,
name: client.user?.name ?? '',
osuId: client.user?.id ?? 0,
name: client.user?.name?.trim() || client.play?.playerName?.trim() || '',
score: client.play?.score ?? 0,
accuracy: client.play?.accuracy ?? 0,
combo: client.play?.combo?.current ?? 0,
@@ -65,3 +77,74 @@ export function parseTosu(message: unknown): TosuFrame | null {
}))
};
}
/** Keeps a websocket to tosu open and remembers its latest tourney frame. */
export class TosuFeed {
latest: TosuFrame | null = null;
latestAt = 0;
connected = false;
private socket: WebSocket | null = null;
private attempts = 0;
private stopped = false;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private url: string,
private onFrame: (frame: TosuFrame) => void,
private log: (...parts: unknown[]) => void = () => {}
) {}
start() {
this.stopped = false;
this.connect();
}
private connect() {
if (this.stopped) return;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
this.attempts = 0;
this.connected = true;
this.log('tosu connected');
};
socket.onmessage = (event) => {
let frame: TosuFrame | null;
try {
frame = parseTosu(JSON.parse(String(event.data)));
} catch {
return;
}
if (!frame) return;
this.latest = frame;
this.latestAt = Date.now();
this.onFrame(frame);
};
socket.onclose = () => {
if (this.socket !== socket) return;
if (this.connected) this.log('tosu disconnected');
this.connected = false;
this.socket = null;
if (this.stopped) return;
// tosu often starts after us; keep trying quietly.
const wait = Math.min(15_000, 1000 * 2 ** this.attempts++);
if (this.attempts === 4)
this.log(`tosu is not answering at ${this.url}; live scores start when it does.`);
this.timer = setTimeout(() => this.connect(), wait);
};
}
/** The latest frame if it is recent enough to trust. */
fresh(maxAgeMs = 5000) {
return this.connected && Date.now() - this.latestAt <= maxAgeMs ? this.latest : null;
}
stop() {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
const socket = this.socket;
this.socket = null;
this.connected = false;
socket?.close();
}
}

View File

@@ -12,7 +12,7 @@ interface Word {
h: number;
}
const plain = (value: string) =>
export const plain = (value: string) =>
value
.normalize('NFKD')
.toLowerCase()
@@ -49,15 +49,19 @@ export function findRoom(tsv: string, roomName: string): { x: number; y: number
return null;
}
function tournamentConfig(existing: string, teamSize: number, height: number) {
/** tournament.cfg with our keys replaced and anything else osu! wrote kept. */
export function tournamentConfig(
existing: string,
options: { teamSize: number; height: number; acronym?: string }
) {
const lines = existing
.split(/\r?\n/)
.filter((line) => !/^\s*(TeamSize|acronym|Height)\s*=/i.test(line));
return [
...lines.filter(Boolean),
`TeamSize = ${teamSize}`,
'acronym = VASH',
`Height = ${height}`,
`TeamSize = ${options.teamSize}`,
`acronym = ${options.acronym ?? 'VASH'}`,
`Height = ${options.height}`,
''
].join('\n');
}
@@ -93,7 +97,7 @@ export class Tourney {
}
const configPath = join(this.directory, 'tournament.cfg');
const existing = await readFile(configPath, 'utf8').catch(() => '');
await writeFile(configPath, tournamentConfig(existing, teamSize, this.height));
await writeFile(configPath, tournamentConfig(existing, { teamSize, height: this.height }));
this.process = Bun.spawn(['wine', this.exe], {
cwd: this.directory,
env: { ...process.env, DISPLAY: this.display },

View File

@@ -3,7 +3,7 @@
FROM oven/bun:1 AS build
WORKDIR /src
COPY tv/main.ts tv/main.ts
COPY agent.ts obs.ts tosu.ts tourney.ts ./
COPY agent.ts obs.ts tosu.ts tourney.ts link.ts relay.ts vash-api.ts ./
RUN bun build tv/main.ts --compile --minify --outfile /vash-tv \
&& bun build agent.ts --compile --minify --outfile /vash-agent

View File

@@ -8,5 +8,5 @@
"noEmit": true,
"types": ["bun"]
},
"include": ["*.ts", "tv/*.ts"]
"include": ["*.ts", "tv/*.ts", "windows/*.ts"]
}

View File

@@ -1,9 +1,10 @@
# Vash TV: streams a Vash broadcast channel with no PC. See streamer/README.md.
# Build from the repository root: docker build -f streamer/tv/Dockerfile -t vash-tv streamer/tv
# Build from the repository root: docker build -f streamer/tv/Dockerfile -t vash-tv streamer
FROM oven/bun:1 AS build
WORKDIR /src
COPY main.ts .
RUN bun build main.ts --compile --minify --outfile /vash-tv
COPY tv/main.ts tv/main.ts
COPY relay.ts vash-api.ts ./
RUN bun build tv/main.ts --compile --minify --outfile /vash-tv
FROM debian:bookworm-slim
RUN apt-get update \

View File

@@ -7,7 +7,7 @@
* YouTube, …), so a destination that fails reconnects on its own without
* touching the others. The same stream is cut into 2-second HLS segments and
* uploaded to storage: that is the live stream on the Vash site and the
* recording afterwards.
* recording afterwards. (Outputs and recording live in ../relay.ts.)
*
* It follows the channel's "Go live" switch on the site:
* VASH_SERVER=https://esports.vash.software VASH_CHANNEL=<id> VASH_TOKEN=<agent token>
@@ -18,8 +18,9 @@
* Optional: MUSIC_URL (looped audio), OVERLAY_ORIGIN (load the overlay from another
* address, e.g. http://127.0.0.1:3102 next to Vash), DURATION (seconds, for tests).
*/
import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
import type { Subprocess } from 'bun';
import { Broadcaster, RtmpIngest, type Source } from '../relay';
import { VashApi, type StreamConfig } from '../vash-api';
const env = process.env;
const server = env.VASH_SERVER?.replace(/\/$/, '');
@@ -33,23 +34,7 @@ const gameplayWidth = Number(env.GAMEPLAY_WIDTH ?? 1280);
const gameplayHeight = Number(env.GAMEPLAY_HEIGHT ?? 720);
const say = (...parts: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...parts);
interface Destination {
id: string;
platform: string;
label: string;
url: string;
}
interface Config {
live: boolean;
width: number;
height: number;
fps: number;
bitrateKbps: number;
overlayUrl: string;
record: boolean;
destinations: Destination[];
}
type Config = StreamConfig;
if (!managed && !((env.OVERLAY_URL || env.INGEST_KEY) && env.RTMP_URL)) {
console.error('Set VASH_SERVER, VASH_CHANNEL and VASH_TOKEN, or OVERLAY_URL and RTMP_URL.');
@@ -58,25 +43,10 @@ if (!managed && !((env.OVERLAY_URL || env.INGEST_KEY) && env.RTMP_URL)) {
// ── Vash API ────────────────────────────────────────────────────────────
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${server}/api/broadcast/${channelId}${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init.headers
},
signal: AbortSignal.timeout(15_000)
});
if (!response.ok)
throw new Error(
`${path}: ${response.status} ${await response.text().catch(() => '')}`.slice(0, 300)
);
return response.json() as Promise<T>;
}
const api = managed ? new VashApi(server!, channelId!, token!) : null;
async function loadConfig(): Promise<Config> {
if (!managed) {
if (!api) {
return {
live: true,
width: Number(env.WIDTH ?? 1280),
@@ -88,7 +58,7 @@ async function loadConfig(): Promise<Config> {
destinations: [{ id: 'rtmp', platform: 'custom', label: 'RTMP', url: env.RTMP_URL! }]
};
}
const config = await api<Config>('/stream');
const config = await api.stream();
// A container next to Vash (or a dev site behind a login) can load the overlay internally.
if (env.OVERLAY_ORIGIN)
config.overlayUrl = config.overlayUrl.replace(
@@ -175,169 +145,53 @@ function stopScreen() {
old.xvfb.kill();
}
// ── Encoder and outputs ────────────────────────────────────────────────
// ── Encoder ─────────────────────────────────────────────────────────────
/** One consumer of the encoded stream: a destination, or the recorder. */
class Output {
state: 'starting' | 'live' | 'reconnecting' | 'error' = 'starting';
error: string | null = null;
private process: Subprocess<'pipe', 'ignore', 'pipe'> | null = null;
private since = 0;
private queue: Uint8Array[] = [];
private queued = 0;
private writing = false;
private attempts = 0;
private stopped = false;
/** Renders the overlay screen (and gameplay, if any) and encodes it once. */
class ScreenEncoder implements Source {
private process: Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
constructor(
readonly id: string,
readonly label: string,
private args: string[]
) {
this.spawn();
get running() {
return !!this.process;
}
private spawn() {
if (this.stopped) return;
async start(config: Config, write: (chunk: Uint8Array) => void) {
if (this.process) return;
await showScreen(config);
const audio = env.PULSE_SOURCE
? ['-f', 'pulse', '-i', env.PULSE_SOURCE]
: env.MUSIC_URL
? ['-stream_loop', '-1', '-i', env.MUSIC_URL]
: ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'];
const kbps = config.bitrateKbps;
const gameplay = gameplayDisplay
? [
'-f',
'x11grab',
'-draw_mouse',
'0',
'-framerate',
String(config.fps),
'-video_size',
`${gameplayWidth}x${gameplayHeight}`,
'-i',
gameplayDisplay
]
: [];
const video = gameplayDisplay
? [
'-filter_complex',
`[1:v]scale=${config.width}:${config.height}:force_original_aspect_ratio=decrease,pad=${config.width}:${config.height}:(ow-iw)/2:(oh-ih)/2[game];[0:v]chromakey=0x00ff00:0.04:0.0[overlay];[game][overlay]overlay=0:0:shortest=1[v]`,
'-map',
'[v]'
]
: ['-map', '0:v'];
const process = Bun.spawn(
[
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-f',
'mpegts',
'-i',
'pipe:0',
...this.args
],
{
stdin: 'pipe',
stdout: 'ignore',
stderr: 'pipe'
}
);
this.process = process;
this.since = Date.now();
this.queue = [];
this.queued = 0;
void (async () => {
let last = '';
for await (const chunk of process.stderr)
last = new TextDecoder().decode(chunk).trim().split('\n').pop() || last;
const code = await process.exited;
if (this.stopped || this.process !== process) return;
this.error = last || `ffmpeg exited with ${code}`;
this.state = this.attempts > 3 ? 'error' : 'reconnecting';
const wait = Math.min(30_000, 2000 * 2 ** this.attempts++);
say(`${this.label}: ${this.error}; retrying in ${Math.round(wait / 1000)}s`);
setTimeout(() => this.spawn(), wait);
})();
}
/**
* Queues bytes in order. Dropping any would corrupt the MPEG-TS timestamps,
* so a destination that falls far behind is restarted instead.
*/
write(chunk: Uint8Array) {
const process = this.process;
if (!process || this.stopped) return;
if (this.state !== 'live' && Date.now() - this.since > 5000 && process.exitCode == null) {
this.state = 'live';
this.error = null;
this.attempts = 0;
}
this.queue.push(chunk);
this.queued += chunk.byteLength;
if (this.queued > 32 * 1024 * 1024) {
this.error = 'Destination too slow; restarting';
this.queue = [];
this.queued = 0;
process.kill();
return;
}
void this.drain(process);
}
private async drain(process: Subprocess<'pipe', 'ignore', 'pipe'>) {
if (this.writing) return;
this.writing = true;
try {
while (this.queue.length && this.process === process) {
const chunk = this.queue.shift()!;
this.queued -= chunk.byteLength;
process.stdin.write(chunk);
const flushed = process.stdin.flush();
if (flushed instanceof Promise) await flushed;
}
} catch {
// The process is exiting; its watcher restarts it.
} finally {
this.writing = false;
}
}
stop() {
this.stopped = true;
try {
this.process?.stdin.end();
} catch {
/* already closed */
}
const process = this.process;
setTimeout(() => process?.kill(), 3000);
}
}
let encoder: Subprocess<'ignore', 'pipe', 'pipe'> | null = null;
const outputs = new Map<string, Output>();
/** OBS (or anything) streams here instead of the built-in screen; nothing is re-encoded. */
const ingestKey = env.INGEST_KEY;
const ingestPort = Number(env.INGEST_PORT ?? 1935);
function startIngest() {
const process = Bun.spawn(
[
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-listen',
'1',
'-i',
`rtmp://0.0.0.0:${ingestPort}/live/${ingestKey}`,
'-c',
'copy',
'-f',
'mpegts',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'pipe' }
);
encoder = process;
say(`Waiting for OBS on rtmp://<this host>:${ingestPort}/live with the ingest key`);
void (async () => {
for await (const chunk of process.stdout)
for (const output of outputs.values()) output.write(chunk);
})();
void (async () => {
await process.exited;
// OBS stopped or dropped; listen again.
if (encoder === process) encoder = null;
})();
}
function startEncoder(config: Config) {
if (ingestKey) return startIngest();
const audio = env.PULSE_SOURCE
? ['-f', 'pulse', '-i', env.PULSE_SOURCE]
: env.MUSIC_URL
? ['-stream_loop', '-1', '-i', env.MUSIC_URL]
: ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'];
const kbps = config.bitrateKbps;
const gameplay = gameplayDisplay
? [
'-f',
'x11grab',
'-draw_mouse',
@@ -345,359 +199,110 @@ function startEncoder(config: Config) {
'-framerate',
String(config.fps),
'-video_size',
`${gameplayWidth}x${gameplayHeight}`,
`${config.width}x${config.height}`,
'-i',
gameplayDisplay
]
: [];
const video = gameplayDisplay
? [
'-filter_complex',
`[1:v]scale=${config.width}:${config.height}:force_original_aspect_ratio=decrease,pad=${config.width}:${config.height}:(ow-iw)/2:(oh-ih)/2[game];[0:v]chromakey=0x00ff00:0.04:0.0[overlay];[game][overlay]overlay=0:0:shortest=1[v]`,
':99',
...gameplay,
...audio,
...video,
'-map',
'[v]'
]
: ['-map', '0:v'];
const process = Bun.spawn(
[
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-f',
'x11grab',
'-draw_mouse',
'0',
'-framerate',
String(config.fps),
'-video_size',
`${config.width}x${config.height}`,
'-i',
':99',
...gameplay,
...audio,
...video,
'-map',
gameplayDisplay ? '2:a' : '1:a',
'-c:v',
'libx264',
'-preset',
env.PRESET ?? 'veryfast',
'-b:v',
`${kbps}k`,
'-maxrate',
`${kbps}k`,
'-bufsize',
`${kbps * 2}k`,
'-pix_fmt',
'yuv420p',
'-g',
String(config.fps * 2),
'-keyint_min',
String(config.fps * 2),
'-sc_threshold',
'0',
'-c:a',
'aac',
'-b:a',
'160k',
'-ar',
'44100',
'-f',
'mpegts',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...env, DISPLAY: ':99' } }
);
encoder = process;
void (async () => {
for await (const chunk of process.stdout)
for (const output of outputs.values()) output.write(chunk);
})();
void (async () => {
let last = '';
for await (const chunk of process.stderr) last = new TextDecoder().decode(chunk).trim() || last;
const code = await process.exited;
if (encoder === process) {
say(`Encoder stopped (${code}): ${last}`);
encoder = null;
}
})();
}
function syncDestinations(config: Config) {
const wanted = new Map(config.destinations.map((destination) => [destination.id, destination]));
for (const [id, output] of outputs) {
if (id === 'recording') continue;
const destination = wanted.get(id);
if (!destination || (output as unknown as { url?: string }).url !== destination.url) {
output.stop();
outputs.delete(id);
if (destination) say(`${output.label}: settings changed, reconnecting`);
else say(`${output.label}: removed`);
}
}
for (const destination of config.destinations) {
if (outputs.has(destination.id)) continue;
const output = new Output(destination.id, destination.label, [
'-c',
'copy',
'-f',
'flv',
destination.url
]);
(output as unknown as { url: string }).url = destination.url;
outputs.set(destination.id, output);
say(`${destination.label}: connecting`);
}
}
// ── Recording (HLS to storage) ─────────────────────────────────────────
let session: { id: string; storagePrefix: string | null; startedAt: number } | null = null;
const vod: { name: string; duration: number }[] = [];
const uploaded = new Set<string>();
let lastVodUpload = 0;
let posterDone = false;
async function upload(
files: Record<string, { body: Uint8Array | string; type: string; cache?: string }>
) {
if (!session?.storagePrefix) return;
const names = Object.keys(files);
const { urls } = await api<{ urls: Record<string, string> }>('/uploads', {
method: 'POST',
body: JSON.stringify({ sessionId: session.id, files: names })
});
await Promise.all(
names.map(async (name) => {
const file = files[name];
const response = await fetch(urls[name], {
method: 'PUT',
body: file.body,
headers: {
'content-type': file.type,
...(file.cache ? { 'cache-control': file.cache } : {})
}
});
if (!response.ok) throw new Error(`upload ${name}: ${response.status}`);
})
);
}
function vodPlaylist(ended: boolean) {
const target = Math.ceil(Math.max(2, ...vod.map((each) => each.duration)));
return [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${target}`,
'#EXT-X-MEDIA-SEQUENCE:0',
`#EXT-X-PLAYLIST-TYPE:${ended ? 'VOD' : 'EVENT'}`,
...vod.flatMap((each) => [`#EXTINF:${each.duration.toFixed(3)},`, `live/${each.name}`]),
...(ended ? ['#EXT-X-ENDLIST'] : [])
].join('\n');
}
/** Uploads new segments, then the live playlist, and now and then the growing VOD playlist. */
async function pumpRecording(final = false) {
if (!session?.storagePrefix) return;
let playlist: string;
try {
playlist = await readFile(`${HLS}/index.m3u8`, 'utf8');
} catch {
return;
}
const entries: { name: string; duration: number }[] = [];
const lines = playlist.split('\n');
for (let index = 0; index < lines.length; index++) {
const match = /^#EXTINF:([\d.]+)/.exec(lines[index]);
if (match && lines[index + 1])
entries.push({ name: lines[index + 1].trim(), duration: Number(match[1]) });
}
const fresh = entries.filter((entry) => !uploaded.has(entry.name));
if (fresh.length) {
const files: Record<string, { body: Uint8Array; type: string }> = {};
for (const entry of fresh)
files[`live/${entry.name}`] = {
body: new Uint8Array(await readFile(`${HLS}/${entry.name}`)),
type: 'video/mp2t'
};
await upload(files);
for (const entry of fresh) {
uploaded.add(entry.name);
vod.push(entry);
}
await upload({
'live/index.m3u8': {
body: playlist,
type: 'application/vnd.apple.mpegurl',
cache: 'no-cache, max-age=1'
}
});
}
if (final || Date.now() - lastVodUpload > 30_000) {
lastVodUpload = Date.now();
await upload({
'vod.m3u8': {
body: vodPlaylist(final),
type: 'application/vnd.apple.mpegurl',
cache: final ? 'max-age=3600' : 'no-cache'
}
});
}
// Local segments that left the live playlist are safely stored; free the disk.
const live = new Set(entries.map((entry) => entry.name));
for (const name of await readdir(HLS))
if (name.endsWith('.ts') && uploaded.has(name) && !live.has(name))
await rm(`${HLS}/${name}`, { force: true });
if (!posterDone && session && Date.now() - session.startedAt > 15_000 && entries.length) {
posterDone = true;
const latest = entries[entries.length - 1].name;
const grab = Bun.spawn([
'ffmpeg',
'-hide_banner',
'-loglevel',
'error',
'-y',
'-i',
`${HLS}/${latest}`,
'-frames:v',
'1',
'-vf',
'scale=640:-2',
`${HLS}/poster.jpg`
]);
if ((await grab.exited) === 0)
await upload({
'poster.jpg': {
body: new Uint8Array(await readFile(`${HLS}/poster.jpg`)),
type: 'image/jpeg'
}
}).catch(() => {});
}
}
// ── On and off air ──────────────────────────────────────────────────────
let onAir = false;
async function goLive(config: Config) {
if (!ingestKey) await showScreen(config);
if (managed) {
const started = await api<{ id: string; storagePrefix: string | null }>('/stream', {
method: 'POST',
body: JSON.stringify({ event: 'start' })
});
session = { ...started, startedAt: Date.now() };
vod.length = 0;
uploaded.clear();
posterDone = false;
}
if (config.record && session?.storagePrefix) {
await rm(HLS, { recursive: true, force: true });
await mkdir(HLS, { recursive: true });
outputs.set(
'recording',
new Output('recording', 'Recording', [
'-c',
'copy',
gameplayDisplay ? '2:a' : '1:a',
'-c:v',
'libx264',
'-preset',
env.PRESET ?? 'veryfast',
'-b:v',
`${kbps}k`,
'-maxrate',
`${kbps}k`,
'-bufsize',
`${kbps * 2}k`,
'-pix_fmt',
'yuv420p',
'-g',
String(config.fps * 2),
'-keyint_min',
String(config.fps * 2),
'-sc_threshold',
'0',
'-c:a',
'aac',
'-b:a',
'160k',
'-ar',
'44100',
'-f',
'hls',
'-hls_time',
'2',
'-hls_list_size',
'8',
'-hls_segment_filename',
`${HLS}/seg_%05d.ts`,
`${HLS}/index.m3u8`
])
'mpegts',
'pipe:1'
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...env, DISPLAY: ':99' } }
);
this.process = process;
void (async () => {
for await (const chunk of process.stdout) write(chunk);
})();
void (async () => {
let last = '';
for await (const chunk of process.stderr)
last = new TextDecoder().decode(chunk).trim() || last;
const code = await process.exited;
if (this.process === process) {
say(`Encoder stopped (${code}): ${last}`);
this.process = null;
}
})();
}
stop() {
const process = this.process;
this.process = null;
process?.kill();
}
syncDestinations(config);
startEncoder(config);
onAir = true;
say(`On air: ${config.destinations.map((each) => each.label).join(', ') || 'recording only'}`);
}
async function goOffAir() {
onAir = false;
encoder?.kill();
encoder = null;
for (const output of outputs.values()) output.stop();
outputs.clear();
await Bun.sleep(2500);
await pumpRecording(true).catch((err) => say('Final upload failed:', (err as Error).message));
if (managed && session) {
await api('/stream', {
method: 'POST',
body: JSON.stringify({
event: 'end',
sessionId: session.id,
durationSec: Math.round((Date.now() - session.startedAt) / 1000)
})
}).catch(() => {});
}
session = null;
say('Off air');
}
async function report() {
if (!managed || !session) return;
await api('/stream', {
method: 'POST',
body: JSON.stringify({
event: 'status',
sessionId: session.id,
durationSec: Math.round((Date.now() - session.startedAt) / 1000),
destinations: [...outputs.values()]
.filter((output) => output.id !== 'recording')
.map((output) => ({
id: output.id,
label: output.label,
state: output.state,
error: output.error
}))
/** OBS (or anything) streams here instead of the built-in screen; nothing is re-encoded. */
const source: Source = env.INGEST_KEY
? new RtmpIngest({
ffmpeg: 'ffmpeg',
host: '0.0.0.0',
port: Number(env.INGEST_PORT ?? 1935),
key: env.INGEST_KEY,
log: say
})
}).catch((err) => say('Status report failed:', (err as Error).message));
}
: new ScreenEncoder();
const broadcaster = new Broadcaster({ api, ffmpeg: 'ffmpeg', hlsDir: HLS, source, log: say });
let stopping = false;
async function shutdown() {
if (stopping) return;
stopping = true;
if (onAir) await goOffAir();
await broadcaster.stop();
stopScreen();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
say(managed ? `Following channel ${channelId} on ${server}` : `Streaming ${env.OVERLAY_URL}`);
say(
managed
? `Following channel ${channelId} on ${server}`
: env.INGEST_KEY
? 'Relaying OBS to one RTMP destination'
: `Streaming ${env.OVERLAY_URL}`
);
if (env.DURATION) setTimeout(shutdown, Number(env.DURATION) * 1000);
let lastReport = 0;
let lastConfig = 0;
while (!stopping) {
if (Date.now() - lastConfig >= 5000) {
lastConfig = Date.now();
try {
const config = await loadConfig();
if (config.live && !onAir) await goLive(config);
else if (!config.live && onAir) await goOffAir();
else if (onAir) {
syncDestinations(config);
if (!encoder) startEncoder(config);
}
await broadcaster.apply(await loadConfig());
} catch (err) {
say('Vash unreachable:', (err as Error).message);
}
}
if (onAir) {
await pumpRecording().catch((err) => say('Recording upload failed:', (err as Error).message));
if (Date.now() - lastReport > 10_000) {
lastReport = Date.now();
await report();
}
}
await broadcaster.tick();
await Bun.sleep(1000);
}

82
streamer/vash-api.ts Normal file
View File

@@ -0,0 +1,82 @@
/** The channel's stream API (/api/broadcast/<id>/…), authorized by its agent token. */
export interface Destination {
id: string;
platform: string;
label: string;
url: string;
}
export interface StreamConfig {
live: boolean;
name?: string;
format?: string;
width: number;
height: number;
fps: number;
bitrateKbps: number;
overlayUrl: string;
record: boolean;
destinations: Destination[];
}
export class VashApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
}
}
export class VashApi {
constructor(
readonly server: string,
readonly channel: string,
private token: string,
/** Extra headers, e.g. Cloudflare Access service-token headers. */
private headers: Record<string, string> = {}
) {
this.server = server.replace(/\/$/, '');
}
async call<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.server}/api/broadcast/${this.channel}${path}`, {
...init,
redirect: 'manual',
headers: {
...this.headers,
authorization: `Bearer ${this.token}`,
'content-type': 'application/json',
...init.headers
},
signal: AbortSignal.timeout(15_000)
});
if (response.status >= 300 && response.status < 400)
throw new VashApiError(
`${path}: redirected to ${response.headers.get('location') ?? 'a login page'}; the site may need a Cloudflare Access service token`,
response.status
);
if (!response.ok)
throw new VashApiError(
`${path}: ${response.status} ${await response.text().catch(() => '')}`.slice(0, 300),
response.status
);
return response.json() as Promise<T>;
}
stream() {
return this.call<StreamConfig>('/stream');
}
report<T = unknown>(body: Record<string, unknown>) {
return this.call<T>('/stream', { method: 'POST', body: JSON.stringify(body) });
}
uploadUrls(sessionId: string, files: string[]) {
return this.call<{ urls: Record<string, string> }>('/uploads', {
method: 'POST',
body: JSON.stringify({ sessionId, files })
});
}
}

4
streamer/windows/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.ps1' {
const text: string;
export default text;
}

716
streamer/windows/cli.ts Normal file
View File

@@ -0,0 +1,716 @@
#!/usr/bin/env bun
/**
* vash-broadcast: sets up and runs an unattended Vash broadcast on a Windows
* PC, one instance per Vash site (dev, staging, prod). Everything is driven
* from an SSH session; the worker itself runs in the signed-in desktop.
*
* Build with `bun run broadcast:build` (dist/vash-broadcast.exe).
*/
import { existsSync } from 'node:fs';
import { copyFile, readdir, readFile, rm, stat } from 'node:fs/promises';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import { VashApi, VashApiError } from '../vash-api';
import {
accessHeaders,
allocatePorts,
layout,
listInstances,
loadConfig,
readJson,
saveConfig,
secret,
validateServer,
VERSION,
writeJson,
type InstanceConfig
} from './config';
import { callWorker, type Runtime } from './control';
import { WindowsDesktop } from './desktop';
import {
findOsu,
installFfmpeg,
installObs,
installSelf,
installTosu,
PINS,
prepareOsu,
writeTosuEnv
} from './install';
import { lockOwner } from './lock';
import { writeObsSettings } from './obs-files';
import {
consoleUser,
endTask,
gpuNames,
registerTask,
runTask,
taskInfo,
unregisterTask
} from './tasks';
import { readSavedLogin } from './tourney';
import {
runTask as runWatchdog,
runWorker,
stopInstanceProcesses,
type SignInState,
type WorkerState
} from './worker';
const HELP = `vash-broadcast ${VERSION}: an unattended Vash broadcast on this Windows PC.
One-time setup, per instance (dev, staging, prod…):
vash-broadcast setup --instance prod --server https://esports.vash.software --channel <id> --token <token>
Installs OBS, tosu and FFmpeg for this instance, a separate osu!tourney folder,
and a scheduled task that runs the worker in your signed-in desktop.
--osu-exe PATH your osu! stable osu!.exe to copy (found automatically otherwise)
--token-stdin read the agent token from standard input instead of --token
--access-id ID --access-secret SECRET
Cloudflare Access service token, for dev/staging sites behind Access
--tourney-height N osu!tourney streamable height (default 720; the window is ~200 px taller)
--encoder amd|x264 amd (default) uses the Radeon's hardware encoder
--no-autostart do not start the worker when you sign in to Windows
vash-broadcast login --instance prod
One-time sign-in of the spectator osu! account, shown to you through an SSH tunnel.
vash-broadcast login --instance dev --copy-from prod
Reuse another instance's remembered login instead (same Windows user).
Every day, nothing: the worker follows the site. Go live and End stream on the channel's
Stream page start and stop the broadcast. To manage it:
vash-broadcast start|stop|restart --instance prod (stop/restart also take --all)
vash-broadcast status --instance prod [--json]
vash-broadcast logs --instance prod [-n 100] [-f]
vash-broadcast snapshot --instance prod save what OBS and osu!tourney show
vash-broadcast doctor --instance prod check the setup and the desktop
vash-broadcast list
vash-broadcast remove --instance prod --yes unregister (keeps files unless --delete-files)
`;
const { values: args, positionals } = parseArgs({
allowPositionals: true,
options: {
instance: { type: 'string', short: 'i' },
server: { type: 'string' },
channel: { type: 'string' },
token: { type: 'string' },
'token-stdin': { type: 'boolean', default: false },
'access-id': { type: 'string' },
'access-secret': { type: 'string' },
'osu-exe': { type: 'string' },
'tourney-height': { type: 'string' },
encoder: { type: 'string' },
'no-autostart': { type: 'boolean', default: false },
autostart: { type: 'boolean', default: false },
all: { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
follow: { type: 'boolean', short: 'f', default: false },
lines: { type: 'string', short: 'n' },
yes: { type: 'boolean', default: false },
'delete-files': { type: 'boolean', default: false },
'copy-from': { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'V', default: false }
}
});
const command = positionals[0];
const say = (line = '') => console.log(line);
class UsageError extends Error {}
function instanceName() {
if (!args.instance)
throw new UsageError('Say which instance: --instance dev|staging|prod (or your own name)');
return args.instance;
}
async function running(name: string) {
return callWorker<WorkerState>(layout(name).runtime, '/status');
}
function requireWindows(what: string) {
if (process.platform !== 'win32') throw new Error(`${what} works only on Windows`);
}
// ── setup ───────────────────────────────────────────────────────────────
async function setup() {
const name = instanceName();
const paths = layout(name);
const existing = await loadConfig(name).catch(() => null);
const others = (await listInstances()).filter((config) => config.name !== name);
const serverArg = args.server ?? existing?.server;
if (!serverArg)
throw new UsageError('--server is required, e.g. --server https://esports.vash.software');
const channel = args.channel ?? existing?.channel;
if (!channel)
throw new UsageError('--channel is required (the channel id from its Broadcast page link)');
let token = args.token ?? process.env.VASH_TOKEN ?? existing?.token;
if (args['token-stdin']) token = (await new Response(Bun.stdin.stream()).text()).trim();
if (!token) throw new UsageError('--token is required (Broadcast page → Agent token)');
const accessId = args['access-id'] ?? process.env.VASH_ACCESS_CLIENT_ID;
const accessSecret = args['access-secret'] ?? process.env.VASH_ACCESS_CLIENT_SECRET;
if (!!accessId !== !!accessSecret)
throw new UsageError('--access-id and --access-secret go together');
const height = Number(args['tourney-height'] ?? existing?.tourneyHeight ?? 720);
const encoder = (args.encoder ?? existing?.encoder ?? 'amd') as InstanceConfig['encoder'];
const config: InstanceConfig = {
version: 1,
name,
server: validateServer(serverArg),
channel,
token,
access: accessId
? { clientId: accessId, clientSecret: accessSecret! }
: (existing?.access ?? null),
ports: existing?.ports ?? allocatePorts(others.map((other) => other.ports)),
obsPassword: existing?.obsPassword ?? secret(),
ingestKey: existing?.ingestKey ?? secret(),
osuSource: existing?.osuSource ?? null,
tourneyHeight: height,
encoder,
autostart: args['no-autostart'] ? false : args.autostart ? true : (existing?.autostart ?? true)
};
for (const other of others)
if (other.server === config.server && other.channel === config.channel)
say(`Note: instance "${other.name}" follows the same channel.`);
if (await running(name))
throw new Error(
`The "${name}" worker is running. Stop it first: vash-broadcast stop --instance ${name}`
);
say(`Checking ${config.server} …`);
const api = new VashApi(config.server, config.channel, config.token, accessHeaders(config));
const stream = await api.stream().catch((err) => {
if (err instanceof VashApiError && err.status === 401)
throw new Error(
'Vash refused the channel id or agent token. Copy them again from the Broadcast page.'
);
throw err;
});
say(
` Channel "${stream.name ?? channel}": ${stream.width}×${stream.height} ${stream.fps} fps, ${stream.bitrateKbps} kbps, ` +
`${stream.destinations.length} destination(s), recording ${stream.record ? 'on' : 'off'}`
);
const progress = (line: string) => say(` ${line}`);
if (process.platform === 'win32') {
say('Installing programs (pinned versions, checksums verified)…');
say(` vash-broadcast: ${await installSelf(paths)}`);
await WindowsDesktop.writeScript(paths.helperScript);
say(` FFmpeg ${PINS.ffmpeg.version}: ${await installFfmpeg(paths, progress)}`);
say(` tosu ${PINS.tosu.version}: ${await installTosu(paths, progress)}`);
await writeTosuEnv(paths, config.ports.tosu);
say(
` OBS ${PINS.obs.version} (portable, this instance only): ${await installObs(paths, progress)}`
);
await writeObsSettings(paths.obsConfig, {
websocketPort: config.ports.obs,
websocketPassword: config.obsPassword,
width: stream.width,
height: stream.height,
fps: stream.fps,
bitrateKbps: stream.bitrateKbps,
encoder: config.encoder,
ingestUrl: `rtmp://127.0.0.1:${config.ports.ingest}/live`,
ingestKey: config.ingestKey
});
const source = await findOsu(args['osu-exe'] ?? config.osuSource);
if (!source)
throw new Error(
'Could not find osu! stable. Pass the path of your osu!.exe with --osu-exe; it is only copied, never changed.'
);
config.osuSource = source;
say(
` osu!tourney folder (osu!.exe copied from ${source}): ${await prepareOsu(paths, source)}`
);
} else say('Not on Windows: saving the configuration only.');
await saveConfig(config);
if (process.platform === 'win32') {
await registerTask({
instance: name,
exe: paths.workerExe,
cwd: paths.root,
autostart: config.autostart
});
say(
` Scheduled task: registered${config.autostart ? ', starts when you sign in to Windows' : ''}`
);
}
say();
say(`Instance "${name}" is set up in ${paths.dir}`);
say(
` Ports: control ${config.ports.control}, OBS ${config.ports.obs}, tosu ${config.ports.tosu}, relay ${config.ports.ingest}`
);
const login = await readSavedLogin(paths.osu);
say();
if (!login.saved)
say(`Next, sign in the spectator account once: vash-broadcast login --instance ${name}`);
else
say(
`Spectator login saved (${login.username}). Start it: vash-broadcast start --instance ${name}`
);
}
// ── start / stop ────────────────────────────────────────────────────────
async function requireDesktop() {
const user = await consoleUser();
if (!user)
throw new Error(
'Nobody is signed in to Windows at the PC. The worker runs in the signed-in desktop; sign in once (or turn on automatic sign-in) and try again.'
);
return user;
}
async function start(name: string) {
requireWindows('start');
const paths = layout(name);
await loadConfig(name);
const status = await running(name);
if (status) {
say(
`"${name}" is already running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`
);
return;
}
await requireDesktop();
const task = await taskInfo(name);
if (!task.exists) throw new Error(`No scheduled task for "${name}". Run setup again.`);
await writeJson(paths.request, { mode: 'run', at: Date.now() });
await runTask(name);
say(`Starting "${name}" in the desktop session…`);
for (let waited = 0; waited < 45; waited++) {
await Bun.sleep(1000);
const status = await running(name);
if (status) {
say(`Running: ${status.state}${status.detail ? ` (${status.detail})` : ''}`);
say(
`Follow it with: vash-broadcast status --instance ${name} or vash-broadcast logs --instance ${name} -f`
);
return;
}
}
const info = await taskInfo(name);
throw new Error(
`The worker did not come up in 45 s (task ${info.state}, last result ${info.lastTaskResult}). See: vash-broadcast logs --instance ${name}`
);
}
async function stop(name: string) {
const paths = layout(name);
const runtime = await readJson<Runtime>(paths.runtime);
const status = await running(name);
if (status) {
say(`Stopping "${name}"${status.onAir ? ' (going off air and finishing the recording)' : ''}…`);
await callWorker(paths.runtime, '/stop', { method: 'POST' });
for (let waited = 0; waited < 120; waited++) {
await Bun.sleep(1000);
if (!(await running(name))) {
say('Stopped.');
return;
}
}
say('It did not stop in 2 minutes; stopping it forcibly.');
} else if (!runtime) {
say(`"${name}" is not running.`);
}
// Forcibly: the task (and its watchdog), the worker, then everything in the instance folder.
if (process.platform === 'win32') {
await endTask(name);
const desktop = new WindowsDesktop(paths.helperScript);
try {
const pids = [runtime?.watchdogPid, runtime?.pid].filter(
(pid): pid is number => !!pid && pid !== process.pid
);
await desktop.kill(pids).catch(() => 0);
const killed = await stopInstanceProcesses(desktop, paths, () => {});
if (killed) say(`Stopped ${killed} process(es) of "${name}".`);
} finally {
desktop.stop();
}
}
await rm(paths.runtime, { force: true });
}
// ── status ──────────────────────────────────────────────────────────────
function age(iso: string | null | undefined) {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
return seconds < 90
? `${seconds}s ago`
: seconds < 5400
? `${Math.round(seconds / 60)} min ago`
: `${Math.round(seconds / 3600)} h ago`;
}
function printState(state: WorkerState) {
say(`State: ${state.state}${state.detail ? ` — ${state.detail}` : ''}`);
if (state.match?.title)
say(
`Match: ${state.match.title}${state.match.lobbyId ? ` (mp/${state.match.lobbyId})` : ' (no osu! lobby)'}`
);
else say('Match: none; the channel is idle');
say(
`Tourney: ${state.tourney.state}${state.tourney.detail ? ` — ${state.tourney.detail}` : ''}${state.tourney.verified ? ' ✓ confirmed' : ''}`
);
say(
`Broadcast: ${state.live ? 'Go live is on' : 'off air'}${state.onAir ? `; OBS ${state.obsStreaming ? 'streaming' : 'NOT streaming'}, relay ${state.relayReceiving ? 'receiving' : 'waiting'}` : ''}`
);
if (state.encoder) say(`Encoder: ${state.encoder}`);
for (const destination of state.destinations)
say(
` → ${destination.label}: ${destination.state}${destination.error ? ` (${destination.error})` : ''}`
);
const components = state.components as Record<string, Record<string, unknown>>;
for (const [label, component] of Object.entries(components))
say(
`${`${label}:`.padEnd(11)}${Object.entries(component)
.filter(([, value]) => value !== null && value !== undefined)
.map(([key, value]) => `${key} ${value}`)
.join(', ')}`
);
say(
`Vash: ${state.vash.connected ? `connected (channel "${state.vash.channel}")` : 'not connected'}${state.vash.lastError ? `; ${state.vash.lastError}` : ''}`
);
if (state.desktop)
say(
`Desktop: ${state.desktop.inputDesktop === 'Default' ? 'unlocked' : 'LOCKED'}, ${state.desktop.screenWidth}×${state.desktop.screenHeight}`
);
for (const warning of state.warnings) say(`Warning: ${warning}`);
say(`Worker: pid ${state.pid}, up since ${state.startedAt} (v${state.version})`);
}
async function status(name: string) {
const paths = layout(name);
const live = await running(name);
if (args.json) {
say(JSON.stringify(live ?? { running: false, last: await readJson(paths.state) }, null, 2));
return;
}
say(`Instance "${name}"`);
if (live) {
printState(live);
return;
}
say('Worker: not running');
const last = await readJson<WorkerState>(paths.state);
if (last)
say(
`Last seen: ${last.state}${last.detail ? ` — ${last.detail}` : ''} (${age(last.updatedAt)})`
);
const login = await readSavedLogin(paths.osu);
say(
`Login: ${login.saved ? `saved (${login.username})` : `not saved; run vash-broadcast login --instance ${name}`}`
);
if (process.platform === 'win32') {
const task = await taskInfo(name).catch(() => null);
if (task)
say(
`Task: ${task.exists ? `${task.state}, last run ${age(task.lastRunTime)} (result ${task.lastTaskResult})${task.autostart ? ', starts at sign-in' : ''}` : 'not registered; run setup'}`
);
}
const owner = await lockOwner(paths.lock);
if (owner && owner.instance !== name)
say(`Tourney: instance "${owner.instance}" is running osu!tourney on this PC`);
}
// ── logs, snapshot, doctor, list, remove ──────────────────────────────────
async function logs(name: string) {
const path = layout(name).log;
if (!existsSync(path)) {
say('No log yet.');
return;
}
const count = Number(args.lines ?? 60);
const text = await readFile(path, 'utf8');
say(text.trimEnd().split('\n').slice(-count).join('\n'));
if (!args.follow) return;
let offset = (await stat(path)).size;
for (;;) {
await Bun.sleep(1000);
const size = (await stat(path).catch(() => null))?.size ?? 0;
if (size < offset) offset = 0;
if (size > offset) {
const file = Bun.file(path).slice(offset, size);
process.stdout.write(await file.text());
offset = size;
}
}
}
async function snapshot(name: string) {
const result = await callWorker<{ files: string[]; errors: string[] }>(
layout(name).runtime,
'/snapshot',
{
method: 'POST',
timeoutMs: 30_000
}
);
if (!result) throw new Error(`"${name}" is not running.`);
for (const file of result.files) say(file);
for (const error of result.errors) say(`Could not capture: ${error}`);
if (result.files.length)
say(
`\nCopy them to your Mac with, for example: scp "${process.env.USERNAME ?? 'you'}@${hostname()}:${result.files[0].replace(/\\/g, '/')}" .`
);
}
async function doctor(name: string) {
const paths = layout(name);
const results: [boolean | null, string, string][] = [];
const check = async (label: string, run: () => Promise<string | [boolean | null, string]>) => {
try {
const outcome = await run();
results.push(
typeof outcome === 'string' ? [true, label, outcome] : [outcome[0], label, outcome[1]]
);
} catch (err) {
results.push([false, label, (err as Error).message]);
}
};
const config = await loadConfig(name);
await check('Vash', async () => {
const stream = await new VashApi(
config.server,
config.channel,
config.token,
accessHeaders(config)
).stream();
return `${config.server}: channel "${stream.name}" answers; Go live is ${stream.live ? 'on' : 'off'}`;
});
for (const [label, path] of [
['OBS', paths.obsExe],
['tosu', paths.tosuExe],
['FFmpeg', paths.ffmpeg],
['osu!tourney folder', paths.osuExe],
['Worker program', paths.workerExe]
] as const)
await check(label, async () =>
existsSync(path) ? path : [false, `missing ${path}; run setup`]
);
await check('Spectator login', async () => {
const login = await readSavedLogin(paths.osu);
return login.saved
? `saved (${login.username})`
: [false, `not saved; run vash-broadcast login --instance ${name}`];
});
if (process.platform === 'win32') {
await check('Scheduled task', async () => {
const task = await taskInfo(name);
return task.exists
? `${task.state}, last result ${task.lastTaskResult}`
: [false, 'not registered; run setup'];
});
await check('Signed-in desktop', async () => `${await requireDesktop()} is signed in`);
await check('GPU', async () => {
const gpus = await gpuNames();
const amd = gpus.some((gpu) => /radeon|amd/i.test(gpu));
return [amd || config.encoder !== 'amd' ? true : null, gpus.join(', ') || 'unknown'];
});
}
await check('Tourney lock', async () => {
const owner = await lockOwner(paths.lock);
return !owner
? 'free'
: owner.instance === name
? 'held by this instance'
: [null, `held by "${owner.instance}"`];
});
const live = await running(name);
if (live) {
results.push([
live.state === 'error' ? false : true,
'Worker',
`${live.state}${live.detail ? ` — ${live.detail}` : ''}`
]);
if (live.desktop)
results.push([
live.desktop.inputDesktop === 'Default',
'Desktop',
live.desktop.inputDesktop === 'Default'
? 'unlocked'
: 'locked: rooms cannot be selected while Windows is locked'
]);
} else if (process.platform === 'win32') {
// Checks that only mean something inside the desktop session run there, through the task.
await rm(paths.doctor, { force: true });
await writeJson(paths.request, { mode: 'doctor', at: Date.now() });
await runTask(name);
let report: { checks: { name: string; ok: boolean; detail: string }[] } | null = null;
for (let waited = 0; waited < 60 && !report; waited++) {
await Bun.sleep(1000);
report = await readJson(paths.doctor);
}
if (!report) results.push([false, 'Desktop checks', 'the task did not report back in 60 s']);
else for (const each of report.checks) results.push([each.ok, each.name, each.detail]);
}
for (const [ok, label, detail] of results)
say(`${ok === true ? ' ok ' : ok === false ? 'FAIL' : ' ?? '} ${label}: ${detail}`);
if (results.some(([ok]) => ok === false)) process.exitCode = 1;
}
async function list() {
const configs = await listInstances();
if (!configs.length) {
say('No instances. Set one up with: vash-broadcast setup --instance prod …');
return;
}
for (const config of configs) {
const live = await running(config.name);
say(
`${config.name.padEnd(10)} ${config.server} ${live ? `${live.state}${live.onAir ? ', on air' : ''}` : 'stopped'}`
);
}
}
async function remove(name: string) {
if (!args.yes) throw new UsageError(`This unregisters "${name}". Add --yes to confirm.`);
await stop(name);
if (process.platform === 'win32') await unregisterTask(name);
if (args['delete-files']) {
await rm(layout(name).dir, { recursive: true, force: true });
say(`Removed "${name}" and its files.`);
} else say(`Unregistered "${name}"; its files are still in ${layout(name).dir}.`);
}
/** Copies osu!'s remembered login from another instance's tourney folder. */
async function copyLogin(name: string, from: string) {
const source = layout(from);
const target = layout(name);
await loadConfig(from);
const login = await readSavedLogin(source.osu);
if (!login.saved) throw new Error(`Instance "${from}" has no saved login to copy.`);
if (await running(name))
throw new Error(`Stop "${name}" first: vash-broadcast stop --instance ${name}`);
for (const file of (await readdir(source.osu)).filter((each) => /^osu!\..+\.cfg$/i.test(each)))
await copyFile(join(source.osu, file), join(target.osu, file));
const copied = await readSavedLogin(target.osu);
if (!copied.saved)
throw new Error('The login did not copy; use vash-broadcast login without --copy-from.');
say(`Copied the remembered login (${copied.username}) from "${from}" to "${name}".`);
say('If osu!tourney still asks to sign in, run vash-broadcast login without --copy-from.');
}
async function login(name: string) {
if (args['copy-from']) return copyLogin(name, args['copy-from']);
requireWindows('login');
const paths = layout(name);
await loadConfig(name);
if (await running(name))
throw new Error(
`Stop the worker first (signing in closes osu!tourney): vash-broadcast stop --instance ${name}`
);
await requireDesktop();
await rm(paths.login, { force: true });
await writeJson(paths.request, { mode: 'login', at: Date.now() });
await runTask(name);
let state: SignInState | null = null;
for (let waited = 0; waited < 60 && state?.state !== 'waiting'; waited++) {
await Bun.sleep(1000);
state = await readJson<SignInState>(paths.login);
if (state?.state === 'failed') throw new Error(state.message);
}
if (!state)
throw new Error(`The sign-in did not start. See: vash-broadcast logs --instance ${name}`);
const user = process.env.USERNAME ?? 'you';
say(`osu! is open on ${hostname()}'s screen for the one-time spectator sign-in.
On your Mac, open a tunnel (leave it running):
ssh -N -L ${state.port}:127.0.0.1:${state.port} ${user}@${hostname().toLowerCase()}
Then open this link in your browser:
http://127.0.0.1:${state.port}/#${state.token}
Sign in with the spectator account (it needs osu!supporter for osu!tourney), tick
Remember username and Remember password, then press "I have signed in: finish".
`);
let last = '';
for (;;) {
await Bun.sleep(2000);
const next = await readJson<SignInState>(paths.login);
if (!next) continue;
if (next.message !== last) say(next.message);
last = next.message;
if (next.state === 'done') {
say(`\nNext: vash-broadcast start --instance ${name}`);
return;
}
if (next.state === 'failed') {
process.exitCode = 1;
return;
}
}
}
// ── main ────────────────────────────────────────────────────────────────
async function main() {
if (args.version) return say(VERSION);
if (args.help || !command) return say(HELP);
switch (command) {
case 'setup':
return setup();
case 'login':
return login(instanceName());
case 'start':
return start(instanceName());
case 'stop':
if (args.all) {
for (const config of await listInstances()) await stop(config.name);
return;
}
return stop(instanceName());
case 'restart':
if (args.all) {
const configs = await listInstances();
for (const config of configs)
if (await running(config.name)) {
await stop(config.name);
await start(config.name);
}
return;
}
await stop(instanceName());
return start(instanceName());
case 'status':
return status(instanceName());
case 'logs':
return logs(instanceName());
case 'snapshot':
return snapshot(instanceName());
case 'doctor':
return doctor(instanceName());
case 'list':
return list();
case 'remove':
return remove(instanceName());
// Started by the scheduled task, inside the desktop session:
case 'task':
process.exitCode = await runWatchdog(instanceName());
return;
case 'worker':
process.exit(await runWorker(instanceName()));
return;
default:
throw new UsageError(`Unknown command "${command}"`);
}
}
try {
await main();
} catch (err) {
console.error(
err instanceof UsageError
? `${err.message}\n\nRun vash-broadcast --help for usage.`
: `Error: ${(err as Error).message}`
);
process.exit(1);
}

239
streamer/windows/config.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* Where an instance lives and what it is configured with.
*
* %LOCALAPPDATA%\Vash\Broadcast\ (VASH_BROADCAST_HOME overrides)
* bin\vash-broadcast.exe the CLI, copied here by setup
* bin\vash-broadcast-worker.exe the same program without a console window
* bin\vash-desktop.ps1 desktop helper (windows, capture, OCR, input)
* downloads\ verified archives, reused by every instance
* tourney.lock which instance runs osu!tourney + tosu now
* instances\<name>\
* config.json server, channel, token, ports, options
* obs\ osu\ tosu\ ffmpeg\ this instance's own copies of each program
* hls\ logs\ snapshots\ recording scratch, logs, diagnostics
* state.json runtime.json what the worker is doing; how to reach it
*
* Every program an instance runs lives under its own folder, so stopping one
* instance can find and stop exactly its processes and nothing else.
*/
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const VERSION = '0.1.0';
export function rootDir() {
if (process.env.VASH_BROADCAST_HOME) return process.env.VASH_BROADCAST_HOME;
if (process.platform === 'win32')
return join(
process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'),
'Vash',
'Broadcast'
);
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'vash-broadcast');
}
export const INSTANCE_NAME = /^[a-z][a-z0-9-]{0,19}$/;
export function instanceDir(name: string) {
if (!INSTANCE_NAME.test(name))
throw new Error(
`Instance names are 1–20 lowercase letters, digits or dashes, starting with a letter (got "${name}")`
);
return join(rootDir(), 'instances', name);
}
/** Paths inside an instance folder. */
export function layout(name: string) {
const dir = instanceDir(name);
const root = rootDir();
return {
root,
dir,
config: join(dir, 'config.json'),
state: join(dir, 'state.json'),
runtime: join(dir, 'runtime.json'),
request: join(dir, 'request.json'),
login: join(dir, 'login.json'),
doctor: join(dir, 'doctor.json'),
logs: join(dir, 'logs'),
log: join(dir, 'logs', 'worker.log'),
snapshots: join(dir, 'snapshots'),
hls: join(dir, 'hls'),
obs: join(dir, 'obs'),
obsExe: join(dir, 'obs', 'bin', '64bit', 'obs64.exe'),
obsConfig: join(dir, 'obs', 'config', 'obs-studio'),
osu: join(dir, 'osu'),
osuExe: join(dir, 'osu', 'osu!.exe'),
tosu: join(dir, 'tosu'),
tosuExe: join(dir, 'tosu', 'tosu.exe'),
ffmpeg: join(dir, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'),
bin: join(root, 'bin'),
cliExe: join(root, 'bin', 'vash-broadcast.exe'),
workerExe: join(root, 'bin', 'vash-broadcast-worker.exe'),
helperScript: join(root, 'bin', 'vash-desktop.ps1'),
downloads: join(root, 'downloads'),
lock: join(root, 'tourney.lock')
};
}
export type Layout = ReturnType<typeof layout>;
export interface Ports {
/** The worker's local control API (status, stop, snapshots). */
control: number;
/** obs-websocket of this instance's OBS. */
obs: number;
/** tosu's HTTP/websocket server. */
tosu: number;
/** RTMP ingest OBS streams into; the relay records and restreams it. */
ingest: number;
/** The one-time sign-in screen viewer. */
viewer: number;
/** Local overlay proxy for sites behind Cloudflare Access (OBS cannot send its headers). */
proxy: number;
}
export interface InstanceConfig {
version: 1;
name: string;
/** The Vash site, e.g. https://esports.vash.software */
server: string;
channel: string;
/** The channel's agent token (Broadcast page → Agent token). */
token: string;
/** Cloudflare Access service token for sites behind Access (dev, staging). */
access: { clientId: string; clientSecret: string } | null;
ports: Ports;
obsPassword: string;
ingestKey: string;
/** Where osu!.exe was copied from for this instance's separate tourney installation. */
osuSource: string | null;
/** osu!tourney's streamable height (tournament.cfg Height); the window is ~200 px taller. */
tourneyHeight: number;
/** 'amd' asks OBS for AMD AMF hardware encoding; 'x264' chooses software encoding on purpose. */
encoder: 'amd' | 'x264';
/** Start the worker when this Windows user signs in. */
autostart: boolean;
}
const PORT_BASE = 47100;
const PORT_STEP = 10;
export function portsForSlot(slot: number): Ports {
const base = PORT_BASE + slot * PORT_STEP;
return {
control: base,
obs: base + 1,
tosu: base + 2,
ingest: base + 3,
viewer: base + 4,
proxy: base + 5
};
}
/** The first port block no other instance uses. */
export function allocatePorts(taken: Ports[]): Ports {
const used = new Set(taken.map((ports) => ports.control));
for (let slot = 0; slot < 50; slot++) {
const ports = portsForSlot(slot);
if (!used.has(ports.control)) return ports;
}
throw new Error('No free port block for another instance');
}
export const secret = (bytes = 18) => randomBytes(bytes).toString('base64url');
export function validateServer(server: string) {
let url: URL;
try {
url = new URL(server);
} catch {
throw new Error(`--server must be a URL like https://esports.vash.software (got "${server}")`);
}
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname))
throw new Error('--server must use https:// (plain http only for localhost)');
return url.origin;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export function validateConfig(value: unknown): InstanceConfig {
const config = value as InstanceConfig;
const problems: string[] = [];
if (!config || typeof config !== 'object') throw new Error('config.json is not an object');
if (config.version !== 1) problems.push('unknown config version');
if (!INSTANCE_NAME.test(config.name ?? '')) problems.push('bad instance name');
try {
validateServer(config.server);
} catch (err) {
problems.push((err as Error).message);
}
if (!UUID.test(config.channel ?? '')) problems.push('channel must be the channel id (a UUID)');
if (typeof config.token !== 'string' || !config.token) problems.push('missing agent token');
for (const key of ['control', 'obs', 'tosu', 'ingest', 'viewer', 'proxy'] as const)
if (!Number.isInteger(config.ports?.[key])) problems.push(`missing ${key} port`);
if (!config.obsPassword || !config.ingestKey) problems.push('missing generated secrets');
if (!(config.tourneyHeight >= 568 && config.tourneyHeight <= 2160))
problems.push('tourneyHeight must be 568–2160');
if (config.encoder !== 'amd' && config.encoder !== 'x264')
problems.push('encoder must be amd or x264');
if (problems.length) throw new Error(`Invalid instance config: ${problems.join('; ')}`);
return config;
}
export async function loadConfig(name: string): Promise<InstanceConfig> {
const path = layout(name).config;
if (!existsSync(path))
throw new Error(
`Instance "${name}" is not set up. Run: vash-broadcast setup --instance ${name} …`
);
return validateConfig(JSON.parse(await readFile(path, 'utf8')));
}
export async function listInstances(): Promise<InstanceConfig[]> {
const dir = join(rootDir(), 'instances');
const names = await readdir(dir).catch(() => [] as string[]);
const configs: InstanceConfig[] = [];
for (const name of names) {
if (!INSTANCE_NAME.test(name)) continue;
try {
configs.push(await loadConfig(name));
} catch {
/* half-set-up instance; ignore */
}
}
return configs;
}
/** Writes JSON through a temporary file so readers never see half a file. */
export async function writeJson(path: string, value: unknown) {
await mkdir(join(path, '..'), { recursive: true });
const temp = `${path}.${process.pid}.tmp`;
await writeFile(temp, JSON.stringify(value, null, '\t'));
await rename(temp, path);
}
export async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as T;
} catch {
return null;
}
}
export async function saveConfig(config: InstanceConfig) {
await writeJson(layout(config.name).config, validateConfig(config));
}
/** Headers for a site behind Cloudflare Access. */
export function accessHeaders(config: InstanceConfig): Record<string, string> {
return config.access
? {
'CF-Access-Client-Id': config.access.clientId,
'CF-Access-Client-Secret': config.access.clientSecret
}
: {};
}

View File

@@ -0,0 +1,44 @@
/**
* The running worker answers on 127.0.0.1:<control port> so the CLI (in an
* SSH session) can ask for its status, stop it, or take snapshots. The token
* changes every run and is kept in runtime.json in the user's own profile.
*/
import { readJson } from './config';
export interface Runtime {
pid: number;
watchdogPid: number | null;
port: number;
token: string;
startedAt: string;
version: string;
}
export async function callWorker<T>(
runtimePath: string,
path: string,
init: { method?: string; timeoutMs?: number } = {}
): Promise<T | null> {
const runtime = await readJson<Runtime>(runtimePath);
if (!runtime) return null;
try {
const response = await fetch(`http://127.0.0.1:${runtime.port}${path}`, {
method: init.method ?? 'GET',
headers: { 'x-vash-token': runtime.token },
signal: AbortSignal.timeout(init.timeoutMs ?? 4000)
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
export function authorized(request: Request, token: string) {
const given = request.headers.get('x-vash-token') ?? '';
if (given.length !== token.length) return false;
let diff = 0;
for (let index = 0; index < token.length; index++)
diff |= given.charCodeAt(index) ^ token.charCodeAt(index);
return diff === 0;
}

View File

@@ -0,0 +1,503 @@
# Vash broadcast desktop helper.
#
# The worker starts this in the signed-in desktop session and sends one JSON
# request per line on stdin; each gets one JSON line back on stdout:
# {"id":1,"cmd":"windows","under":"C:\\...\\osu"} -> {"id":1,"ok":true,"result":[...]}
#
# It needs Windows PowerShell 5.1 (powershell.exe, part of Windows): the
# Windows.Media.Ocr API used for reading osu!tourney's room list is not
# reachable from PowerShell 7. Coordinates are physical screen pixels.
$ErrorActionPreference = 'Stop'
# Explicit UTF-8 streams: [Console]::InputEncoding cannot be set when there is no console window.
$utf8 = New-Object System.Text.UTF8Encoding $false
$stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)
$stdout = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)
$stdout.AutoFlush = $true
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public class VashWindow {
public long hwnd { get; set; }
public int pid { get; set; }
public string title { get; set; }
public string className { get; set; }
public string exe { get; set; }
public bool visible { get; set; }
public bool minimized { get; set; }
public int x { get; set; }
public int y { get; set; }
public int width { get; set; }
public int height { get; set; }
public int clientX { get; set; }
public int clientY { get; set; }
public int clientWidth { get; set; }
public int clientHeight { get; set; }
}
public class VashProcess {
public int pid { get; set; }
public string path { get; set; }
}
public static class VashDesktop {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
[StructLayout(LayoutKind.Sequential)] struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[StructLayout(LayoutKind.Sequential)] struct HARDWAREINPUT { public uint uMsg; public ushort wParamL; public ushort wParamH; }
[StructLayout(LayoutKind.Explicit)] struct InputUnion {
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
[FieldOffset(0)] public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)] struct INPUT { public uint type; public InputUnion U; }
[StructLayout(LayoutKind.Sequential)] struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool GetClientRect(IntPtr hWnd, out RECT r);
[DllImport("user32.dll")] static extern bool ClientToScreen(IntPtr hWnd, ref POINT p);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, IntPtr w, string l, uint flags, uint timeout, out IntPtr result);
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int cmd);
[DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll")] static extern bool AttachThreadInput(uint a, uint b, bool attach);
[DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern uint SendInput(uint n, INPUT[] inputs, int size);
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern IntPtr OpenInputDesktop(uint flags, bool inherit, uint access);
[DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr h);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool GetUserObjectInformation(IntPtr h, int index, StringBuilder sb, int len, out int needed);
[DllImport("user32.dll")] static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")] static extern int GetSystemMetrics(int index);
[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LASTINPUTINFO info);
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool QueryFullProcessImageName(IntPtr h, uint flags, StringBuilder sb, ref uint size);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[DllImport("kernel32.dll")] static extern uint WTSGetActiveConsoleSessionId();
[DllImport("kernel32.dll")] static extern bool ProcessIdToSessionId(uint pid, out uint session);
[DllImport("kernel32.dll")] static extern uint SetThreadExecutionState(uint flags);
/** Keeps the PC awake and the display on while this helper runs (it reverts when the helper exits). */
public static bool StayAwake(bool on) {
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
uint flags = on ? 0x80000000u | 0x00000001u | 0x00000002u : 0x80000000u;
return SetThreadExecutionState(flags) != 0;
}
public static string Init() {
try {
// Per-monitor aware v2: every rectangle and click is in physical pixels.
if (SetProcessDpiAwarenessContext(new IntPtr(-4))) return "per-monitor-v2";
return "unchanged";
} catch (Exception e) {
return "unavailable: " + e.Message;
}
}
public static string ProcessPath(uint pid) {
IntPtr h = OpenProcess(0x1000, false, pid);
if (h == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(1024);
uint size = (uint)sb.Capacity;
return QueryFullProcessImageName(h, 0, sb, ref size) ? sb.ToString() : null;
} finally {
CloseHandle(h);
}
}
static bool Under(string path, string dir) {
if (String.IsNullOrEmpty(dir)) return true;
if (path == null) return false;
string prefix = dir.TrimEnd('\\') + "\\";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
public static VashWindow Describe(IntPtr h) {
uint pid;
GetWindowThreadProcessId(h, out pid);
var title = new StringBuilder(GetWindowTextLength(h) + 1);
GetWindowText(h, title, title.Capacity);
var cls = new StringBuilder(256);
GetClassName(h, cls, cls.Capacity);
RECT r, c;
GetWindowRect(h, out r);
GetClientRect(h, out c);
var origin = new POINT();
ClientToScreen(h, ref origin);
string path = ProcessPath(pid);
return new VashWindow {
hwnd = h.ToInt64(), pid = (int)pid, title = title.ToString(), className = cls.ToString(),
exe = path, visible = IsWindowVisible(h), minimized = IsIconic(h),
x = r.Left, y = r.Top, width = r.Right - r.Left, height = r.Bottom - r.Top,
clientX = origin.X, clientY = origin.Y, clientWidth = c.Right - c.Left, clientHeight = c.Bottom - c.Top
};
}
/** Top-level windows of processes whose executable is under dir (all when empty). */
public static VashWindow[] Windows(string dir) {
var handles = new List<IntPtr>();
EnumWindows((h, l) => { handles.Add(h); return true; }, IntPtr.Zero);
var result = new List<VashWindow>();
var paths = new Dictionary<uint, string>();
foreach (var h in handles) {
uint pid;
GetWindowThreadProcessId(h, out pid);
string path;
if (!paths.TryGetValue(pid, out path)) { path = ProcessPath(pid); paths[pid] = path; }
if (!Under(path, dir)) continue;
result.Add(Describe(h));
}
return result.ToArray();
}
public static VashWindow Window(long hwnd) {
var h = new IntPtr(hwnd);
return IsWindow(h) ? Describe(h) : null;
}
public static VashProcess[] Processes(string dir) {
var result = new List<VashProcess>();
foreach (var p in Process.GetProcesses()) {
try {
string path = ProcessPath((uint)p.Id);
if (path != null && Under(path, dir)) result.Add(new VashProcess { pid = p.Id, path = path });
} catch { }
finally { p.Dispose(); }
}
return result.ToArray();
}
public static int Kill(int[] pids) {
int killed = 0;
foreach (int pid in pids) {
try { using (var p = Process.GetProcessById(pid)) { p.Kill(); killed++; } } catch { }
}
return killed;
}
/** Asks every top-level window of a process to close, like clicking its X. */
public static int CloseProcessWindows(int pid) {
int posted = 0;
foreach (var w in Windows(null)) {
if (w.pid != pid) continue;
if (PostMessage(new IntPtr(w.hwnd), 0x0010, IntPtr.Zero, IntPtr.Zero)) posted++;
}
return posted;
}
/** "Default" when the desktop is unlocked; "Winlogon" (or null) when locked or on a secure screen. */
public static string InputDesktop() {
IntPtr d = OpenInputDesktop(0, false, 0x0001);
if (d == IntPtr.Zero) return null;
try {
var sb = new StringBuilder(256);
int needed;
return GetUserObjectInformation(d, 2, sb, sb.Capacity * 2, out needed) ? sb.ToString() : null;
} finally {
CloseDesktop(d);
}
}
public static Dictionary<string, object> Session() {
uint session;
ProcessIdToSessionId((uint)Process.GetCurrentProcess().Id, out session);
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO)) };
GetLastInputInfo(ref info);
return new Dictionary<string, object> {
{ "sessionId", (int)session },
{ "consoleSessionId", (int)WTSGetActiveConsoleSessionId() },
{ "inputDesktop", InputDesktop() },
{ "screenWidth", GetSystemMetrics(0) },
{ "screenHeight", GetSystemMetrics(1) },
{ "idleMs", unchecked(Environment.TickCount - (int)info.dwTime) }
};
}
static void Key(ushort vk, bool up) {
var input = new INPUT { type = 1 };
input.U.ki.wVk = vk;
input.U.ki.dwFlags = up ? 0x0002u : 0u;
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
}
/** Brings a window to the front; Windows only allows this in some cases, so try the known ways. */
public static bool Focus(long hwnd) {
var h = new IntPtr(hwnd);
if (!IsWindow(h)) return false;
if (IsIconic(h)) ShowWindow(h, 9);
if (GetForegroundWindow() == h) return true;
IntPtr fg = GetForegroundWindow();
uint ignored;
uint fgThread = fg == IntPtr.Zero ? 0 : GetWindowThreadProcessId(fg, out ignored);
uint me = GetCurrentThreadId();
bool attached = fgThread != 0 && fgThread != me && AttachThreadInput(me, fgThread, true);
BringWindowToTop(h);
SetForegroundWindow(h);
if (attached) AttachThreadInput(me, fgThread, false);
Thread.Sleep(80);
if (GetForegroundWindow() == h) return true;
// The process that sent the last input may set the foreground window: tap Alt, then retry.
Key(0x12, false);
Key(0x12, true);
SetForegroundWindow(h);
Thread.Sleep(120);
return GetForegroundWindow() == h;
}
public static bool Move(long hwnd, int x, int y) {
// SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
return SetWindowPos(new IntPtr(hwnd), IntPtr.Zero, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010);
}
public static bool SetTitle(long hwnd, string title) {
IntPtr result;
// WM_SETTEXT with a timeout, so a busy window cannot hang the helper.
return SendMessageTimeout(new IntPtr(hwnd), 0x000C, IntPtr.Zero, title, 0x0002, 2000, out result) != IntPtr.Zero;
}
static ImageCodecInfo Codec(string mime) {
foreach (var codec in ImageCodecInfo.GetImageEncoders()) if (codec.MimeType == mime) return codec;
return null;
}
static void Save(Bitmap bmp, string path, string format, long quality) {
if (format == "jpeg") {
var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
bmp.Save(path, Codec("image/jpeg"), parameters);
} else {
bmp.Save(path, ImageFormat.Png);
}
}
/**
* Copies a screen rectangle to an image file, optionally scaled (to at most maxWidth,
* or by scale) and inverted (dark text on light reads better for OCR). Returns
* the saved size. Fails while the desktop is locked.
*/
public static int[] Capture(int x, int y, int w, int h, string path, string format, int maxWidth, double scale, bool invert, long quality) {
using (var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb)) {
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
}
double factor = scale > 0 ? scale : 1.0;
if (maxWidth > 0 && w * factor > maxWidth) factor = (double)maxWidth / w;
int ow = Math.Max(1, (int)Math.Round(w * factor));
int oh = Math.Max(1, (int)Math.Round(h * factor));
if (ow == w && oh == h && !invert) {
Save(bmp, path, format, quality);
return new[] { ow, oh };
}
using (var output = new Bitmap(ow, oh, PixelFormat.Format24bppRgb)) {
using (var g = Graphics.FromImage(output)) {
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
var attributes = new ImageAttributes();
if (invert) {
attributes.SetColorMatrix(new ColorMatrix(new float[][] {
new float[] { -1, 0, 0, 0, 0 },
new float[] { 0, -1, 0, 0, 0 },
new float[] { 0, 0, -1, 0, 0 },
new float[] { 0, 0, 0, 1, 0 },
new float[] { 1, 1, 1, 0, 1 }
}));
}
g.DrawImage(bmp, new Rectangle(0, 0, ow, oh), 0, 0, w, h, GraphicsUnit.Pixel, attributes);
}
Save(output, path, format, quality);
}
return new[] { ow, oh };
}
}
public static bool Click(int x, int y, bool right) {
if (!SetCursorPos(x, y)) return false;
Thread.Sleep(60);
var down = new INPUT { type = 0 };
down.U.mi.dwFlags = right ? 0x0008u : 0x0002u;
var up = new INPUT { type = 0 };
up.U.mi.dwFlags = right ? 0x0010u : 0x0004u;
uint sent = SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(60);
sent += SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
return sent == 2;
}
public static int TypeText(string text) {
int sent = 0;
foreach (char ch in text) {
var down = new INPUT { type = 1 };
down.U.ki.wScan = ch;
down.U.ki.dwFlags = 0x0004;
var up = new INPUT { type = 1 };
up.U.ki.wScan = ch;
up.U.ki.dwFlags = 0x0004 | 0x0002;
sent += (int)SendInput(1, new[] { down }, Marshal.SizeOf(typeof(INPUT)));
sent += (int)SendInput(1, new[] { up }, Marshal.SizeOf(typeof(INPUT)));
Thread.Sleep(15);
}
return sent / 2;
}
public static void PressKey(ushort vk) {
Key(vk, false);
Thread.Sleep(30);
Key(vk, true);
}
}
'@
$script:dpi = [VashDesktop]::Init()
# ── Windows OCR (Windows.Media.Ocr, part of Windows 10 and 11) ─────────────
$script:ocr = $null
function Initialize-Ocr {
if ($null -ne $script:ocr) { return }
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Foundation.IAsyncOperation`1, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType = WindowsRuntime]
$null = [Windows.Storage.Streams.RandomAccessStream, Windows.Storage.Streams, ContentType = WindowsRuntime]
$script:asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } |
Select-Object -First 1
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) {
try {
$null = [Windows.Globalization.Language, Windows.Globalization, ContentType = WindowsRuntime]
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage([Windows.Globalization.Language]::new('en-US'))
} catch { }
}
if ($null -eq $engine) {
throw 'Windows OCR has no language available. Add English under Settings > Time & language > Language & region.'
}
$script:ocr = $engine
}
function Await($operation, [Type]$type) {
$task = $script:asTask.MakeGenericMethod($type).Invoke($null, @($operation))
if (-not $task.Wait(20000)) { throw 'Windows OCR timed out' }
$task.Result
}
function Read-Text([string]$path) {
Initialize-Ocr
$file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($path)) ([Windows.Storage.StorageFile])
$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
try {
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$result = Await ($script:ocr.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
} finally {
$stream.Dispose()
}
$lines = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
$left = [double]::MaxValue; $top = [double]::MaxValue; $right = 0.0; $bottom = 0.0
foreach ($word in $line.Words) {
$r = $word.BoundingRect
$left = [Math]::Min($left, $r.X); $top = [Math]::Min($top, $r.Y)
$right = [Math]::Max($right, $r.X + $r.Width); $bottom = [Math]::Max($bottom, $r.Y + $r.Height)
}
if ($right -le 0) { continue }
$null = $lines.Add(@{ text = $line.Text; x = $left; y = $top; width = $right - $left; height = $bottom - $top })
}
return , $lines.ToArray()
}
# ── Request loop ──────────────────────────────────────────────────────────
function Reply($id, [bool]$ok, $value) {
$message = @{ id = $id; ok = $ok }
if ($ok) { $message.result = $value } else { $message.error = [string]$value }
$stdout.WriteLine((ConvertTo-Json -InputObject $message -Compress -Depth 6))
}
Reply 0 $true @{ ready = $true; dpi = $script:dpi; powershell = $PSVersionTable.PSVersion.ToString() }
while ($true) {
$line = $stdin.ReadLine()
if ($null -eq $line) { break }
if (-not $line.Trim()) { continue }
$id = $null
try {
$request = ConvertFrom-Json $line
$id = $request.id
$cmd = [string]$request.cmd
$result = $null
if ($cmd -eq 'ping') {
$result = @{ pong = $true }
} elseif ($cmd -eq 'awake') {
$result = [VashDesktop]::StayAwake([bool]$request.on)
} elseif ($cmd -eq 'session') {
$result = [VashDesktop]::Session()
} elseif ($cmd -eq 'windows') {
$result = [VashDesktop]::Windows([string]$request.under)
} elseif ($cmd -eq 'window') {
$result = [VashDesktop]::Window([long]$request.hwnd)
} elseif ($cmd -eq 'processes') {
$result = [VashDesktop]::Processes([string]$request.under)
} elseif ($cmd -eq 'kill') {
$result = [VashDesktop]::Kill([int[]]@($request.pids))
} elseif ($cmd -eq 'close') {
$result = [VashDesktop]::CloseProcessWindows([int]$request.pid)
} elseif ($cmd -eq 'focus') {
$result = [VashDesktop]::Focus([long]$request.hwnd)
} elseif ($cmd -eq 'move') {
$result = [VashDesktop]::Move([long]$request.hwnd, [int]$request.x, [int]$request.y)
} elseif ($cmd -eq 'title') {
$result = [VashDesktop]::SetTitle([long]$request.hwnd, [string]$request.title)
} elseif ($cmd -eq 'capture') {
$format = if ($request.format) { [string]$request.format } else { 'png' }
$quality = if ($request.quality) { [long]$request.quality } else { 80 }
$size = [VashDesktop]::Capture([int]$request.x, [int]$request.y, [int]$request.width, [int]$request.height,
[string]$request.path, $format, [int]$request.maxWidth, [double]$request.scale, [bool]$request.invert, $quality)
$result = @{ width = $size[0]; height = $size[1] }
} elseif ($cmd -eq 'ocr') {
$result = Read-Text ([string]$request.path)
} elseif ($cmd -eq 'ocrReady') {
Initialize-Ocr
$result = @{ language = $script:ocr.RecognizerLanguage.LanguageTag; maxDimension = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension }
} elseif ($cmd -eq 'click') {
$result = [VashDesktop]::Click([int]$request.x, [int]$request.y, [bool]$request.right)
} elseif ($cmd -eq 'type') {
$result = [VashDesktop]::TypeText([string]$request.text)
} elseif ($cmd -eq 'key') {
[VashDesktop]::PressKey([uint16]$request.vk)
$result = $true
} else {
throw "Unknown command $cmd"
}
Reply $id $true $result
} catch {
Reply $id $false $_.Exception.Message
}
}

278
streamer/windows/desktop.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* The desktop the worker drives: windows, screen capture, OCR and input.
* On Windows this is a long-running PowerShell helper (desktop.ps1) in the
* signed-in session; tests use a fake with the same interface.
*/
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { Subprocess } from 'bun';
import helperScript from './desktop.ps1' with { type: 'text' };
export interface WindowInfo {
hwnd: number;
pid: number;
title: string;
className: string;
exe: string | null;
visible: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
height: number;
/** The client area (no title bar or borders) in screen coordinates. */
clientX: number;
clientY: number;
clientWidth: number;
clientHeight: number;
}
export interface SessionInfo {
sessionId: number;
consoleSessionId: number;
/** "Default" when unlocked; "Winlogon" or null when locked or on a secure screen. */
inputDesktop: string | null;
screenWidth: number;
screenHeight: number;
idleMs: number;
}
export interface OcrLine {
text: string;
x: number;
y: number;
width: number;
height: number;
}
export interface CaptureOptions {
x: number;
y: number;
width: number;
height: number;
path: string;
format?: 'png' | 'jpeg';
maxWidth?: number;
scale?: number;
invert?: boolean;
quality?: number;
}
export interface Desktop {
session(): Promise<SessionInfo>;
/** Top-level windows of processes whose executable is under a folder. */
windows(under: string): Promise<WindowInfo[]>;
window(hwnd: number): Promise<WindowInfo | null>;
processes(under: string): Promise<{ pid: number; path: string }[]>;
kill(pids: number[]): Promise<number>;
/** Posts WM_CLOSE to a process's windows. */
close(pid: number): Promise<number>;
focus(hwnd: number): Promise<boolean>;
move(hwnd: number, x: number, y: number): Promise<boolean>;
setTitle(hwnd: number, title: string): Promise<boolean>;
capture(options: CaptureOptions): Promise<{ width: number; height: number }>;
ocr(path: string): Promise<OcrLine[]>;
ocrReady(): Promise<{ language: string; maxDimension: number }>;
click(x: number, y: number, right?: boolean): Promise<boolean>;
type(text: string): Promise<number>;
key(vk: number): Promise<void>;
/** Keeps the PC from sleeping and the display on while the helper runs. */
stayAwake(on: boolean): Promise<boolean>;
stop(): void;
}
interface Pending {
resolve(value: unknown): void;
reject(err: Error): void;
timer: ReturnType<typeof setTimeout>;
}
export class WindowsDesktop implements Desktop {
private process: Subprocess<'pipe', 'pipe', 'pipe'> | null = null;
private pending = new Map<number, Pending>();
private nextId = 1;
private ready: Promise<void> | null = null;
private lastError = '';
constructor(
private scriptPath: string,
private log: (...parts: unknown[]) => void = () => {}
) {}
/**
* Writes the helper next to the program when it differs from this build's copy,
* with a byte order mark: Windows PowerShell reads a script without one as ANSI.
*/
static async writeScript(path: string) {
const content = `\uFEFF${helperScript.replace(/^\uFEFF/, '')}`;
const current = existsSync(path) ? await readFile(path, 'utf8') : null;
if (current === content) return;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
}
private start(): Promise<void> {
if (this.ready) return this.ready;
this.ready = (async () => {
await WindowsDesktop.writeScript(this.scriptPath);
const process = Bun.spawn(
[
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
this.scriptPath
],
{ stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', windowsHide: true }
);
this.process = process;
let resolveReady!: () => void;
let rejectReady!: (err: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
this.pending.set(0, {
resolve: () => resolveReady(),
reject: rejectReady,
timer: setTimeout(() => rejectReady(new Error('The desktop helper did not start')), 60_000)
});
void this.readLines(process);
void (async () => {
for await (const chunk of process.stderr)
this.lastError = new TextDecoder().decode(chunk).trim().slice(-500) || this.lastError;
})();
void process.exited.then((code) => {
if (this.process !== process) return;
this.process = null;
this.ready = null;
const error = new Error(
`The desktop helper exited (${code})${this.lastError ? `: ${this.lastError}` : ''}`
);
for (const waiter of this.pending.values()) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
this.pending.clear();
});
await ready;
})();
this.ready.catch(() => {
this.ready = null;
this.process?.kill();
this.process = null;
});
return this.ready;
}
private async readLines(process: Subprocess<'pipe', 'pipe', 'pipe'>) {
let buffer = '';
const decoder = new TextDecoder();
for await (const chunk of process.stdout) {
buffer += decoder.decode(chunk, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
let message: { id: number; ok: boolean; result?: unknown; error?: string };
try {
message = JSON.parse(line.replace(/^\uFEFF/, ''));
} catch {
this.log('desktop helper:', line.slice(0, 200));
continue;
}
const waiter = this.pending.get(message.id);
if (!waiter) continue;
this.pending.delete(message.id);
clearTimeout(waiter.timer);
if (message.ok) waiter.resolve(message.result ?? null);
else waiter.reject(new Error(message.error ?? 'desktop helper error'));
}
}
}
async call<T>(cmd: string, args: Record<string, unknown> = {}, timeoutMs = 20_000): Promise<T> {
await this.start();
const process = this.process;
if (!process) throw new Error('The desktop helper is not running');
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
// A stuck helper is restarted on the next call.
this.process = null;
this.ready = null;
process.kill();
reject(new Error(`The desktop helper did not answer ${cmd} in time`));
}, timeoutMs);
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
process.stdin.write(`${JSON.stringify({ id, cmd, ...args })}\n`);
void process.stdin.flush();
});
}
session() {
return this.call<SessionInfo>('session');
}
windows(under: string) {
return this.call<WindowInfo[]>('windows', { under });
}
window(hwnd: number) {
return this.call<WindowInfo | null>('window', { hwnd });
}
processes(under: string) {
return this.call<{ pid: number; path: string }[]>('processes', { under });
}
kill(pids: number[]) {
return pids.length ? this.call<number>('kill', { pids }) : Promise.resolve(0);
}
close(pid: number) {
return this.call<number>('close', { pid });
}
focus(hwnd: number) {
return this.call<boolean>('focus', { hwnd });
}
move(hwnd: number, x: number, y: number) {
return this.call<boolean>('move', { hwnd, x, y });
}
setTitle(hwnd: number, title: string) {
return this.call<boolean>('title', { hwnd, title });
}
capture(options: CaptureOptions) {
return this.call<{ width: number; height: number }>('capture', { ...options });
}
ocr(path: string) {
return this.call<OcrLine[]>('ocr', { path }, 40_000);
}
ocrReady() {
return this.call<{ language: string; maxDimension: number }>('ocrReady', {}, 40_000);
}
click(x: number, y: number, right = false) {
return this.call<boolean>('click', { x, y, right });
}
type(text: string) {
return this.call<number>('type', { text });
}
async key(vk: number) {
await this.call('key', { vk });
}
stayAwake(on: boolean) {
return this.call<boolean>('awake', { on });
}
stop() {
const process = this.process;
this.process = null;
this.ready = null;
try {
process?.stdin.end();
} catch {
/* already gone */
}
setTimeout(() => process?.kill(), 1000);
}
}

122
streamer/windows/fakes.ts Normal file
View File

@@ -0,0 +1,122 @@
/** Test doubles for the desktop helper and tosu. */
import type { TosuFeed, TosuFrame } from '../tosu';
import type { CaptureOptions, Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop';
export function windowInfo(overrides: Partial<WindowInfo> = {}): WindowInfo {
return {
hwnd: 100,
pid: 10,
title: 'osu!',
className: 'WindowsForms10.Window.8.app.0.1',
exe: 'C:\\vash\\osu\\osu!.exe',
visible: true,
minimized: false,
x: 0,
y: 0,
width: 1296,
height: 959,
clientX: 8,
clientY: 31,
clientWidth: 1280,
clientHeight: 920,
...overrides
};
}
export class FakeDesktop implements Desktop {
sessionInfo: SessionInfo = {
sessionId: 1,
consoleSessionId: 1,
inputDesktop: 'Default',
screenWidth: 1920,
screenHeight: 1080,
idleMs: 0
};
running: { pid: number; path: string }[] = [];
windowList: WindowInfo[] = [];
ocrLines: OcrLine[] = [];
calls: { cmd: string; args: unknown[] }[] = [];
focusResult = true;
private note(cmd: string, ...args: unknown[]) {
this.calls.push({ cmd, args });
}
called(cmd: string) {
return this.calls.filter((call) => call.cmd === cmd);
}
async session() {
return this.sessionInfo;
}
async windows(under: string) {
this.note('windows', under);
return this.windowList;
}
async window(hwnd: number) {
return this.windowList.find((window) => window.hwnd === hwnd) ?? null;
}
async processes(under: string) {
return this.running.filter((process) =>
process.path.toLowerCase().startsWith(under.toLowerCase())
);
}
async kill(pids: number[]) {
this.note('kill', pids);
this.running = this.running.filter((process) => !pids.includes(process.pid));
return pids.length;
}
async close(pid: number) {
this.note('close', pid);
this.running = this.running.filter((process) => process.pid !== pid);
this.windowList = this.windowList.filter((window) => window.pid !== pid);
return 1;
}
async focus(hwnd: number) {
this.note('focus', hwnd);
return this.focusResult;
}
async move(hwnd: number, x: number, y: number) {
this.note('move', hwnd, x, y);
return true;
}
async setTitle(hwnd: number, title: string) {
this.note('title', hwnd, title);
const window = this.windowList.find((each) => each.hwnd === hwnd);
if (window) window.title = title;
return true;
}
async capture(options: CaptureOptions) {
this.note('capture', options);
const scale = options.scale ?? 1;
return { width: Math.round(options.width * scale), height: Math.round(options.height * scale) };
}
async ocr(path: string) {
this.note('ocr', path);
return this.ocrLines;
}
async ocrReady() {
return { language: 'en-US', maxDimension: 2600 };
}
async click(x: number, y: number) {
this.note('click', x, y);
return true;
}
async type(text: string) {
return text.length;
}
async key() {}
async stayAwake() {
return true;
}
stop() {}
}
/** Stands in for TosuFeed: whatever frame the test sets is the fresh one. */
export function fakeTosu(frame: TosuFrame | null = null) {
const feed = { frame, fresh: () => feed.frame };
return feed as unknown as TosuFeed & { frame: TosuFrame | null };
}
export function tourneyFrame(left: string, right: string): TosuFrame {
return { ipcState: 1, beatmapId: null, progress: null, teams: { left, right }, clients: [] };
}

381
streamer/windows/install.ts Normal file
View File

@@ -0,0 +1,381 @@
/**
* Installs an instance's programs from pinned, checksummed releases, and
* copies this program into a fixed place for the scheduled task.
*
* Nothing is downloaded on start: setup verifies each archive's SHA-256
* against the pin below before extracting it. To upgrade one, change its pin
* (URL and checksum from the release page), rebuild, and re-run setup.
*/
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { Layout } from './config';
import { powershell } from './tasks';
export interface Pin {
name: string;
version: string;
url: string;
sha256: string;
bytes: number;
}
/** Checked against each project's GitHub release API on 2026-09-29. */
export const PINS = {
obs: {
name: 'OBS Studio',
version: '32.2.2',
url: 'https://github.com/obsproject/obs-studio/releases/download/32.2.2/OBS-Studio-32.2.2-Windows-x64.zip',
sha256: '4d6e40e3ab155f56b30de517380566a206d74b63cdf5ad49aa596924768f97e1',
bytes: 187_851_583
},
tosu: {
name: 'tosu',
version: '4.26.2',
url: 'https://github.com/tosuapp/tosu/releases/download/v4.26.2/tosu-windows-v4.26.2.zip',
sha256: 'd34c2bfc495959fcc79ff01f10b7dc9aea77eb508ed23c8f0d0fb8342773faa3',
bytes: 0
},
ffmpeg: {
name: 'FFmpeg (gyan.dev essentials)',
version: '9.0.2',
url: 'https://github.com/GyanD/codexffmpeg/releases/download/9.0.2/ffmpeg-9.0.2-essentials_build.zip',
sha256: '60f467265b1e312373dbcd92200c2618a74850f98d3d078e94296bb3fa2047ba',
bytes: 114_768_076
}
} satisfies Record<string, Pin>;
export async function sha256File(path: string) {
const hash = createHash('sha256');
const file = Bun.file(path);
for await (const chunk of file.stream()) hash.update(chunk);
return hash.digest('hex');
}
/** Downloads a pinned archive once into downloads\ and verifies it. */
export async function fetchPinned(pin: Pin, downloads: string, log: (line: string) => void) {
await mkdir(downloads, { recursive: true });
const target = join(downloads, basename(new URL(pin.url).pathname));
if (existsSync(target) && (await sha256File(target)) === pin.sha256) return target;
const partial = `${target}.partial`;
log(`Downloading ${pin.name} ${pin.version}…`);
const response = await fetch(pin.url, { redirect: 'follow' });
if (!response.ok || !response.body)
throw new Error(`Download of ${pin.name} failed: ${response.status} ${pin.url}`);
const total = Number(response.headers.get('content-length')) || pin.bytes || 0;
const hash = createHash('sha256');
const writer = Bun.file(partial).writer();
let received = 0;
let shown = 0;
for await (const chunk of response.body) {
hash.update(chunk);
writer.write(chunk);
received += chunk.byteLength;
if (total && received / total - shown >= 0.2) {
shown = received / total;
log(` ${Math.round(shown * 100)}%`);
}
}
await writer.end();
const digest = hash.digest('hex');
if (digest !== pin.sha256) {
await rm(partial, { force: true });
throw new Error(
`${pin.name} download did not match its pinned SHA-256 (got ${digest}); refusing to install it`
);
}
await rename(partial, target);
return target;
}
/** Extracts a zip with Windows' built-in tar (bsdtar), falling back to PowerShell. */
export async function extractZip(archive: string, into: string) {
await rm(into, { recursive: true, force: true });
await mkdir(into, { recursive: true });
// Windows 10 and 11 ship bsdtar, which reads zip; another tar earlier in PATH may not.
const tarExe =
process.platform === 'win32'
? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe')
: 'tar';
const tar = Bun.spawn([tarExe, '-xf', archive, '-C', into], {
stdout: 'ignore',
stderr: 'pipe',
windowsHide: true
});
if ((await tar.exited) === 0) return;
if (process.platform !== 'win32')
throw new Error(`Could not extract ${archive}: ${await new Response(tar.stderr).text()}`);
await powershell(
`Expand-Archive -LiteralPath $env:VASH_ARCHIVE -DestinationPath $env:VASH_INTO -Force`,
{ VASH_ARCHIVE: archive, VASH_INTO: into }
).catch((err) => {
throw new Error(`Could not extract ${archive}: ${(err as Error).message}`, { cause: err });
});
}
/** Finds a file by name anywhere under a directory. */
export async function findFile(dir: string, name: string): Promise<string | null> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries)
if (entry.isFile() && entry.name.toLowerCase() === name.toLowerCase())
return join(dir, entry.name);
for (const entry of entries)
if (entry.isDirectory()) {
const found = await findFile(join(dir, entry.name), name);
if (found) return found;
}
return null;
}
const MARKER = '.vash-version';
async function installedVersion(dir: string) {
return (await readFile(join(dir, MARKER), 'utf8').catch(() => '')).trim();
}
async function markInstalled(dir: string, pin: Pin) {
await writeFile(join(dir, MARKER), `${pin.version} ${pin.sha256}\n`);
}
export type InstallResult = 'installed' | 'current';
/**
* A portable OBS in the instance folder: its own settings, scenes, profile and
* websocket, separate from any OBS the user already has.
*/
export async function installObs(
paths: Layout,
log: (line: string) => void
): Promise<InstallResult> {
const pin = PINS.obs;
if (
existsSync(paths.obsExe) &&
(await installedVersion(paths.obs)) === `${pin.version} ${pin.sha256}`
)
return 'current';
const archive = await fetchPinned(pin, paths.downloads, log);
// Keep OBS's settings (config\) across an upgrade; replace only the program files.
const keep = join(paths.dir, 'obs-config.keep');
const hadConfig = existsSync(join(paths.obs, 'config'));
if (hadConfig) await rename(join(paths.obs, 'config'), keep);
const staging = `${paths.obs}.new`;
log(`Extracting ${pin.name}…`);
await extractZip(archive, staging);
const exe = await findFile(staging, 'obs64.exe');
if (!exe) throw new Error('The OBS archive has no obs64.exe');
// obs64.exe sits in <root>\bin\64bit; portable mode keeps settings in <root>\config.
const obsRoot = dirname(dirname(dirname(exe)));
await rm(paths.obs, { recursive: true, force: true });
await rename(obsRoot, paths.obs);
await rm(staging, { recursive: true, force: true });
if (hadConfig) await rename(keep, join(paths.obs, 'config'));
await writeFile(join(paths.obs, 'portable_mode.txt'), '');
await markInstalled(paths.obs, pin);
return 'installed';
}
export async function installTosu(
paths: Layout,
log: (line: string) => void
): Promise<InstallResult> {
const pin = PINS.tosu;
if (
existsSync(paths.tosuExe) &&
(await installedVersion(paths.tosu)) === `${pin.version} ${pin.sha256}`
)
return 'current';
const archive = await fetchPinned(pin, paths.downloads, log);
const staging = `${paths.tosu}.new`;
await extractZip(archive, staging);
const exe = await findFile(staging, 'tosu.exe');
if (!exe) throw new Error('The tosu archive has no tosu.exe');
// Keep tosu's own files (tosu.env, static\, settings\) across an upgrade.
await mkdir(paths.tosu, { recursive: true });
for (const entry of await readdir(dirname(exe)))
await rename(join(dirname(exe), entry), join(paths.tosu, entry)).catch(async () => {
await rm(join(paths.tosu, entry), { recursive: true, force: true });
await rename(join(dirname(exe), entry), join(paths.tosu, entry));
});
await rm(staging, { recursive: true, force: true });
await markInstalled(paths.tosu, pin);
return 'installed';
}
export async function installFfmpeg(
paths: Layout,
log: (line: string) => void
): Promise<InstallResult> {
const pin = PINS.ffmpeg;
const dir = dirname(paths.ffmpeg);
if (existsSync(paths.ffmpeg) && (await installedVersion(dir)) === `${pin.version} ${pin.sha256}`)
return 'current';
const archive = await fetchPinned(pin, paths.downloads, log);
const staging = `${dir}.new`;
await extractZip(archive, staging);
const exe = await findFile(staging, 'ffmpeg.exe');
if (!exe) throw new Error('The FFmpeg archive has no ffmpeg.exe');
await mkdir(dir, { recursive: true });
await copyFile(exe, paths.ffmpeg);
await rm(staging, { recursive: true, force: true });
await markInstalled(dir, pin);
return 'installed';
}
/** Where osu! stable is usually installed, newest convention first. */
export function osuCandidates(env: Record<string, string | undefined> = process.env) {
const local = env.LOCALAPPDATA;
return [
local && join(local, 'osu!', 'osu!.exe'),
env['ProgramFiles(x86)'] && join(env['ProgramFiles(x86)']!, 'osu!', 'osu!.exe'),
env.ProgramFiles && join(env.ProgramFiles, 'osu!', 'osu!.exe'),
'C:\\osu!\\osu!.exe'
].filter((path): path is string => !!path);
}
/** osu! stable's path from its osu:// URL handler, if registered. */
async function osuFromRegistry(): Promise<string | null> {
if (process.platform !== 'win32') return null;
for (const key of [
'HKCU\\Software\\Classes\\osu\\shell\\open\\command',
'HKCR\\osu\\shell\\open\\command'
]) {
const reg = Bun.spawn(['reg', 'query', key, '/ve'], {
stdout: 'pipe',
stderr: 'ignore',
windowsHide: true
});
const output = await new Response(reg.stdout).text();
if ((await reg.exited) !== 0) continue;
const match = /"([^"]*osu!\.exe)"/i.exec(output);
if (match && existsSync(match[1])) return match[1];
}
return null;
}
export async function findOsu(explicit: string | null): Promise<string | null> {
if (explicit) return existsSync(explicit) ? explicit : null;
for (const path of osuCandidates()) if (existsSync(path)) return path;
return osuFromRegistry();
}
/**
* A separate osu! stable folder for osu!tourney, as the official setup guide
* says: copy osu!.exe into an empty folder; it installs itself on first run.
* The source installation is only read, never changed.
*/
export async function prepareOsu(paths: Layout, source: string): Promise<InstallResult> {
if (existsSync(paths.osuExe)) return 'current';
const sourceDir = dirname(source).toLowerCase();
if (sourceDir === paths.osu.toLowerCase())
throw new Error('The tourney installation cannot be the same folder as its source');
await mkdir(paths.osu, { recursive: true });
await copyFile(source, paths.osuExe);
return 'installed';
}
/** tosu reads this file from its own folder on start. */
export function tosuEnv(port: number) {
return [
'DEBUG_LOG=false',
'ENABLE_AUTOUPDATE=false',
'OPEN_DASHBOARD_ON_STARTUP=false',
'',
'SHOW_MP_COMMANDS=false',
'CALCULATE_PP=false',
'READ_MANIA_SCROLL_SPEED=false',
'',
'ENABLE_KEY_OVERLAY=false',
'ENABLE_INGAME_OVERLAY=false',
'',
'POLL_RATE=150',
'PRECISE_DATA_POLL_RATE=100',
'',
'INGAME_OVERLAY_KEYBIND=Control + Shift + Space',
'INGAME_OVERLAY_MAX_FPS=60',
'',
'SERVER_IP=127.0.0.1',
`SERVER_PORT=${port}`,
'ALLOWED_IPS=127.0.0.1,localhost',
'',
'STATIC_FOLDER_PATH=./static',
''
].join('\n');
}
export async function writeTosuEnv(paths: Layout, port: number) {
await mkdir(paths.tosu, { recursive: true });
await writeFile(join(paths.tosu, 'tosu.env'), tosuEnv(port));
}
// ── This program ────────────────────────────────────────────────────────
const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
/**
* Sets a PE executable's subsystem: 2 (GUI) opens no console window when the
* scheduled task starts it; 3 (console) keeps output in an SSH session. This
* is the same header field Bun's --windows-hide-console sets.
*/
export function setSubsystem(exe: Uint8Array, subsystem: number) {
const view = new DataView(exe.buffer, exe.byteOffset, exe.byteLength);
if (view.getUint16(0, true) !== 0x5a4d) throw new Error('Not an MZ executable');
const pe = view.getUint32(0x3c, true);
if (view.getUint32(pe, true) !== 0x00004550) throw new Error('No PE header');
const optional = pe + 24;
const magic = view.getUint16(optional, true);
if (magic !== 0x20b && magic !== 0x10b) throw new Error('Unknown optional header');
const before = view.getUint16(optional + 68, true);
if (before !== IMAGE_SUBSYSTEM_WINDOWS_CUI && before !== IMAGE_SUBSYSTEM_WINDOWS_GUI)
throw new Error(`Unexpected subsystem ${before}`);
view.setUint16(optional + 68, subsystem, true);
// The checksum is not checked for user-mode programs; clear it rather than leave it wrong.
view.setUint32(optional + 64, 0, true);
return exe;
}
export function subsystemOf(exe: Uint8Array) {
const view = new DataView(exe.buffer, exe.byteOffset, exe.byteLength);
const pe = view.getUint32(0x3c, true);
return view.getUint16(pe + 24 + 68, true);
}
/**
* Copies the running program into bin\ (the scheduled task points there, so
* the downloaded file can be moved or deleted) and writes the windowless copy.
*/
export async function installSelf(paths: Layout): Promise<InstallResult> {
const self = process.execPath;
if (!/vash-broadcast[^\\/]*\.exe$/i.test(basename(self)))
throw new Error(
`setup must run from the compiled vash-broadcast.exe (running ${basename(self)}); build it with bun run broadcast:build`
);
await mkdir(paths.bin, { recursive: true });
const bytes = new Uint8Array(await readFile(self));
const same = async (path: string, expected: Uint8Array) => {
if (!existsSync(path) || (await stat(path)).size !== expected.byteLength) return false;
return Buffer.from(await readFile(path)).equals(Buffer.from(expected));
};
const console = setSubsystem(new Uint8Array(bytes), IMAGE_SUBSYSTEM_WINDOWS_CUI);
const windowless = setSubsystem(new Uint8Array(bytes), IMAGE_SUBSYSTEM_WINDOWS_GUI);
let changed = false;
for (const [path, content] of [
[paths.cliExe, console],
[paths.workerExe, windowless]
] as const) {
if (self.toLowerCase() === path.toLowerCase() || (await same(path, content))) continue;
try {
await writeFile(`${path}.new`, content);
await rm(path, { force: true });
await rename(`${path}.new`, path);
changed = true;
} catch (err) {
throw new Error(
`Could not update ${path} (${(err as Error).message}). Stop running instances first: vash-broadcast stop --all`,
{ cause: err }
);
}
}
return changed ? 'installed' : 'current';
}

68
streamer/windows/lock.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* One osu!tourney + tosu stack per PC. tosu reads every osu! process on the
* machine and serves the first tourney manager it finds, so two instances
* running osu!tourney at once would mix up their scores and rooms. An
* instance holds this lock while it runs the stack; others wait and say why.
*/
import { open, readFile, rm } from 'node:fs/promises';
export interface LockOwner {
instance: string;
pid: number;
at: string;
}
function alive(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM: exists but belongs to someone else.
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}
export async function lockOwner(path: string): Promise<LockOwner | null> {
try {
const owner = JSON.parse(await readFile(path, 'utf8')) as LockOwner;
return owner && alive(owner.pid) ? owner : null;
} catch {
return null;
}
}
/** Takes the lock for this process, or returns who holds it. */
export async function acquireLock(
path: string,
instance: string,
pid = process.pid
): Promise<{ ok: true } | { ok: false; owner: LockOwner }> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const file = await open(path, 'wx');
await file.writeFile(JSON.stringify({ instance, pid, at: new Date().toISOString() }));
await file.close();
return { ok: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
}
const owner = await lockOwner(path);
if (owner && owner.pid === pid) return { ok: true };
if (owner) return { ok: false, owner };
// The holder is gone; clear its lock and try again.
await rm(path, { force: true });
}
const owner = await lockOwner(path);
return owner
? { ok: false, owner }
: { ok: false, owner: { instance: 'unknown', pid: 0, at: '' } };
}
export async function releaseLock(path: string, pid = process.pid) {
try {
const owner = JSON.parse(await readFile(path, 'utf8')) as LockOwner;
if (owner.pid === pid) await rm(path, { force: true });
} catch {
/* not ours or already gone */
}
}

40
streamer/windows/log.ts Normal file
View File

@@ -0,0 +1,40 @@
/** Appends timestamped lines to the instance's log, keeping the last few files. */
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
const MAX_BYTES = 10 * 1024 * 1024;
const KEEP = 5;
/** Rotation keeps a multi-hour broadcast's log bounded: worker.log, worker.1.log, … */
function rotate(path: string) {
if (!existsSync(path) || statSync(path).size < MAX_BYTES) return;
const numbered = (n: number) => path.replace(/\.log$/, `.${n}.log`);
rmSync(numbered(KEEP), { force: true });
for (let n = KEEP - 1; n >= 1; n--)
if (existsSync(numbered(n))) renameSync(numbered(n), numbered(n + 1));
renameSync(path, numbered(1));
}
export function fileLogger(path: string, alsoConsole = false) {
mkdirSync(dirname(path), { recursive: true });
let lines = 0;
return (...parts: unknown[]) => {
const text = parts
.map((part) =>
part instanceof Error
? part.message
: typeof part === 'string'
? part
: JSON.stringify(part)
)
.join(' ');
const line = `${new Date().toISOString()} ${text}\n`;
if (alsoConsole) process.stdout.write(line);
try {
if (++lines % 200 === 0) rotate(path);
appendFileSync(path, line);
} catch {
/* a full or locked disk must not stop the broadcast */
}
};
}

View File

@@ -0,0 +1,313 @@
/**
* Keeps the instance's OBS scene as the broadcast needs it, over obs-websocket:
*
* scene "Vash"
* Overlay browser source, the channel's overlay at canvas size (on top)
* Gameplay window capture of this instance's osu!tourney window, with its
* audio, the control panel cropped off, fitted to the canvas;
* hidden until the requested room is being spectated
*
* Every call is idempotent: it creates what is missing and corrects what
* drifted, so it runs after every OBS start and whenever something changes.
*/
import type { Obs } from '../obs';
import { gameplayHeight } from './tourney';
export const SCENE = 'Vash';
export const GAMEPLAY = 'Gameplay';
export const OVERLAY = 'Overlay';
/** OBS's window setting: "title:class:exe" with '#' and ':' escaped (libobs window-helpers.c). */
export function windowSetting(target: { title: string; className: string; exe: string }) {
const encode = (value: string) => value.replace(/#/g, '#22').replace(/:/g, '#3A');
return `${encode(target.title)}:${encode(target.className)}:${encode(target.exe)}`;
}
/** Match the window by exact title only; another osu! window can never be picked up instead. */
const WINDOW_PRIORITY_TITLE = 1;
const METHOD_WGC = 2;
const OBS_ALIGN_TOP_LEFT = 5;
export interface SceneTarget {
canvas: { width: number; height: number };
fps: number;
overlayUrl: string;
/** The tourney window to capture, when one exists. */
capture: { title: string; className: string; exe: string } | null;
showGameplay: boolean;
ingest: { server: string; key: string };
}
interface SceneItem {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
}
interface Transform {
sourceWidth: number;
sourceHeight: number;
cropBottom: number;
boundsType: string;
boundsWidth: number;
boundsHeight: number;
}
const differs = (current: Record<string, unknown>, wanted: Record<string, unknown>) =>
Object.entries(wanted).some(
([key, value]) => JSON.stringify(current[key]) !== JSON.stringify(value)
);
export class ObsScene {
private lastFull = '';
constructor(
private obs: Obs,
private log: (...parts: unknown[]) => void
) {}
/** Creates or corrects the scene; cheap when nothing changed. */
async reconcile(target: SceneTarget) {
const key = JSON.stringify({ ...target, showGameplay: undefined });
if (key !== this.lastFull) {
await this.full(target);
this.lastFull = key;
}
await this.fitGameplay(target);
}
/** Forget what was applied, e.g. after OBS restarted. */
reset() {
this.lastFull = '';
}
private async full(target: SceneTarget) {
const obs = this.obs;
const { scenes, currentProgramSceneName } = await obs.request<{
scenes: { sceneName: string }[];
currentProgramSceneName: string;
}>('GetSceneList');
if (!scenes.some((scene) => scene.sceneName === SCENE)) {
await obs.request('CreateScene', { sceneName: SCENE });
this.log(`OBS: created scene ${SCENE}`);
}
if (currentProgramSceneName !== SCENE)
await obs.request('SetCurrentProgramScene', { sceneName: SCENE });
const { inputs } = await obs.request<{ inputs: { inputName: string }[] }>('GetInputList');
const names = new Set(inputs.map((input) => input.inputName));
const gameplaySettings = {
window: target.capture ? windowSetting(target.capture) : '',
priority: WINDOW_PRIORITY_TITLE,
method: METHOD_WGC,
cursor: false,
client_area: true,
capture_audio: true,
compatibility: false
};
const overlaySettings = {
url: target.overlayUrl,
width: target.canvas.width,
height: target.canvas.height,
fps_custom: true,
fps: target.fps,
reroute_audio: true,
shutdown: false,
restart_when_active: false
};
await this.ensureInput(
names,
GAMEPLAY,
'window_capture',
gameplaySettings,
target.showGameplay
);
await this.ensureInput(names, OVERLAY, 'browser_source', overlaySettings, true);
// Both in the scene, the overlay above gameplay.
let items = await this.items();
for (const name of [GAMEPLAY, OVERLAY])
if (!items.some((item) => item.sourceName === name))
await obs.request('CreateSceneItem', { sceneName: SCENE, sourceName: name });
items = await this.items();
const overlay = items.find((item) => item.sourceName === OVERLAY)!;
const top = Math.max(...items.map((item) => item.sceneItemIndex));
if (overlay.sceneItemIndex !== top)
await obs.request('SetSceneItemIndex', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemIndex: top
});
await obs.request('SetSceneItemTransform', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemTransform: {
positionX: 0,
positionY: 0,
alignment: OBS_ALIGN_TOP_LEFT,
boundsType: 'OBS_BOUNDS_NONE',
scaleX: 1,
scaleY: 1,
rotation: 0
}
});
if (!overlay.sceneItemEnabled)
await obs.request('SetSceneItemEnabled', {
sceneName: SCENE,
sceneItemId: overlay.sceneItemId,
sceneItemEnabled: true
});
// Only the capture's own audio and the overlay: never the PC's desktop audio or microphone.
const special = await obs.request<Record<string, string | null>>('GetSpecialInputs');
for (const name of Object.values(special))
if (name)
await obs.request('SetInputMute', { inputName: name, inputMuted: true }).catch(() => {});
const service = await obs
.request<{ streamServiceType: string; streamServiceSettings: Record<string, unknown> }>(
'GetStreamServiceSettings'
)
.catch(() => null);
const wanted = { server: target.ingest.server, key: target.ingest.key };
if (
!service ||
service.streamServiceType !== 'rtmp_custom' ||
differs(service.streamServiceSettings, wanted)
)
await obs.request('SetStreamServiceSettings', {
streamServiceType: 'rtmp_custom',
streamServiceSettings: { ...wanted, use_auth: false, bwtest: false }
});
}
private async ensureInput(
names: Set<string>,
inputName: string,
inputKind: string,
inputSettings: Record<string, unknown>,
enabled: boolean
) {
const obs = this.obs;
if (!names.has(inputName)) {
await obs.request('CreateInput', {
sceneName: SCENE,
inputName,
inputKind,
inputSettings,
sceneItemEnabled: enabled
});
this.log(`OBS: added ${inputName}`);
return;
}
const current = await obs.request<{
inputKind: string;
inputSettings: Record<string, unknown>;
}>('GetInputSettings', { inputName });
if (current.inputKind !== inputKind) {
// Someone replaced it with another kind of source; start over.
await obs.request('RemoveInput', { inputName });
await obs.request('CreateInput', {
sceneName: SCENE,
inputName,
inputKind,
inputSettings,
sceneItemEnabled: enabled
});
this.log(`OBS: replaced ${inputName}`);
return;
}
if (differs(current.inputSettings, inputSettings))
await obs.request('SetInputSettings', { inputName, inputSettings, overlay: true });
}
private async items() {
const { sceneItems } = await this.obs.request<{ sceneItems: SceneItem[] }>('GetSceneItemList', {
sceneName: SCENE
});
return sceneItems;
}
/** Crops the control panel off the capture and fits the gameplay to the canvas; shows or hides it. */
private async fitGameplay(target: SceneTarget) {
const obs = this.obs;
const item = (await this.items()).find((each) => each.sourceName === GAMEPLAY);
if (!item) return;
if (item.sceneItemEnabled !== target.showGameplay)
await obs.request('SetSceneItemEnabled', {
sceneName: SCENE,
sceneItemId: item.sceneItemId,
sceneItemEnabled: target.showGameplay
});
const { sceneItemTransform: transform } = await obs.request<{ sceneItemTransform: Transform }>(
'GetSceneItemTransform',
{ sceneName: SCENE, sceneItemId: item.sceneItemId }
);
if (!transform.sourceWidth || !transform.sourceHeight) return;
const cropBottom = Math.max(0, transform.sourceHeight - gameplayHeight(transform.sourceWidth));
if (
transform.cropBottom === cropBottom &&
transform.boundsType === 'OBS_BOUNDS_SCALE_INNER' &&
transform.boundsWidth === target.canvas.width &&
transform.boundsHeight === target.canvas.height
)
return;
await obs.request('SetSceneItemTransform', {
sceneName: SCENE,
sceneItemId: item.sceneItemId,
sceneItemTransform: {
positionX: 0,
positionY: 0,
alignment: OBS_ALIGN_TOP_LEFT,
cropTop: 0,
cropLeft: 0,
cropRight: 0,
cropBottom,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsAlignment: 0,
boundsWidth: target.canvas.width,
boundsHeight: target.canvas.height,
rotation: 0
}
});
this.log(
`OBS: gameplay ${transform.sourceWidth}×${transform.sourceHeight}, control panel cropped (${cropBottom} px)`
);
}
async streaming() {
const status = await this.obs.request<{ outputActive: boolean; outputReconnecting: boolean }>(
'GetStreamStatus'
);
return status;
}
async startStream() {
await this.obs.request('StartStream');
}
async stopStream() {
await this.obs.request('StopStream');
}
/** The profile's simple-mode encoder; OBS switches it to x264 by itself when AMF is unavailable. */
async profileEncoder() {
const { currentValue } = await this.obs.request<{ currentValue: string | null }>(
'GetProfileParameter',
{ parameterCategory: 'SimpleOutput', parameterName: 'StreamEncoder' }
);
return currentValue;
}
/** A PNG of the program output, for diagnostics. */
async screenshot(width = 1280) {
const { imageData } = await this.obs.request<{ imageData: string }>('GetSourceScreenshot', {
sourceName: SCENE,
imageFormat: 'png',
imageWidth: width
});
return Buffer.from(imageData.replace(/^data:image\/png;base64,/, ''), 'base64');
}
}

View File

@@ -0,0 +1,212 @@
/**
* Settings for an instance's portable OBS, written while OBS is closed:
* no first-run wizard or crash dialog, websocket on the instance's port with
* its own password, and a "Vash" profile with the channel's size, frame rate
* and bitrate, AMD AMF hardware encoding, and the local relay as its server.
* Keys OBS wrote itself are kept.
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export type Ini = Map<string, Map<string, string>>;
export function parseIni(text: string): Ini {
const ini: Ini = new Map();
let section = new Map<string, string>();
ini.set('', section);
for (const raw of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
const header = /^\[(.+)\]$/.exec(line);
if (header) {
section = ini.get(header[1]) ?? new Map();
ini.set(header[1], section);
continue;
}
const eq = line.indexOf('=');
if (eq > 0) section.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
}
return ini;
}
export function stringifyIni(ini: Ini) {
const parts: string[] = [];
for (const [name, section] of ini) {
if (!section.size) continue;
if (name) parts.push(`[${name}]`);
for (const [key, value] of section) parts.push(`${key}=${value}`);
parts.push('');
}
return parts.join('\r\n');
}
export function setIni(
ini: Ini,
values: Record<string, Record<string, string | number | boolean>>
) {
for (const [name, entries] of Object.entries(values)) {
const section = ini.get(name) ?? new Map<string, string>();
ini.set(name, section);
for (const [key, value] of Object.entries(entries)) section.set(key, String(value));
}
return ini;
}
async function updateIni(
path: string,
values: Record<string, Record<string, string | number | boolean>>
) {
const existing = existsSync(path) ? await readFile(path, 'utf8') : '';
const next = stringifyIni(setIni(parseIni(existing), values));
await mkdir(join(path, '..'), { recursive: true });
if (next !== existing) await writeFile(path, next);
}
export const PROFILE = 'Vash';
export const COLLECTION = 'Vash';
export interface ObsSettings {
websocketPort: number;
websocketPassword: string;
width: number;
height: number;
fps: number;
bitrateKbps: number;
encoder: 'amd' | 'x264';
ingestUrl: string;
ingestKey: string;
}
/** Everything a fresh or existing portable OBS needs; safe to run before every start. */
export async function writeObsSettings(configDir: string, settings: ObsSettings) {
// Settings OBS reads from the app (global.ini) and user (user.ini) files;
// written to both so either OBS layout picks them up.
const general = {
General: {
FirstRun: true,
ConfirmOnExit: false,
WarnBeforeStartingStream: false,
WarnBeforeStoppingStream: false,
WarnBeforeStoppingRecord: false,
EnableAutoUpdates: false,
OpenStatsOnStartup: false
},
BasicWindow: {
PreviewEnabled: false,
SysTrayEnabled: true,
SysTrayWhenStarted: true,
SysTrayMinimizeToTray: true
},
Basic: {
Profile: PROFILE,
ProfileDir: PROFILE,
SceneCollection: COLLECTION,
SceneCollectionFile: COLLECTION,
ConfigOnNewProfile: false
}
};
await updateIni(join(configDir, 'user.ini'), general);
await updateIni(join(configDir, 'global.ini'), general);
const profile = join(configDir, 'basic', 'profiles', PROFILE);
await updateIni(join(profile, 'basic.ini'), {
General: { Name: PROFILE },
Output: {
Mode: 'Simple',
Reconnect: true,
RetryDelay: 2,
MaxRetries: 10000,
DelayEnable: false
},
SimpleOutput: {
StreamEncoder: settings.encoder,
VBitrate: settings.bitrateKbps,
ABitrate: 160,
UseAdvanced: false,
StreamAudioEncoder: 'aac',
Preset: 'veryfast',
AMDPreset: 'quality',
RecQuality: 'Stream'
},
Stream1: { IgnoreRecommended: true },
Video: {
BaseCX: settings.width,
BaseCY: settings.height,
OutputCX: settings.width,
OutputCY: settings.height,
FPSType: 1,
FPSInt: settings.fps,
ScaleType: 'bicubic',
ColorFormat: 'NV12',
ColorSpace: '709',
ColorRange: 'Partial'
},
Audio: { SampleRate: 48000, ChannelSetup: 'Stereo' }
});
await writeFile(
join(profile, 'service.json'),
JSON.stringify({
type: 'rtmp_custom',
settings: {
server: settings.ingestUrl,
key: settings.ingestKey,
use_auth: false,
bwtest: false
}
})
);
const websocket = join(configDir, 'plugin_config', 'obs-websocket');
await mkdir(websocket, { recursive: true });
await writeFile(
join(websocket, 'config.json'),
JSON.stringify({
first_load: false,
server_enabled: true,
server_port: settings.websocketPort,
alerts_enabled: false,
auth_required: true,
server_password: settings.websocketPassword
})
);
// A leftover run sentinel makes OBS ask about safe mode after a forced stop; the
// worker only force-stops its own OBS, so clear it.
const sentinel = join(configDir, '.sentinel');
for (const name of await readdir(sentinel).catch(() => [] as string[]))
if (name.startsWith('run_')) await rm(join(sentinel, name), { force: true });
}
/** Arguments for obs64.exe, started with its bin\64bit folder as the working directory. */
export const OBS_ARGS = [
'--portable',
'--multi',
'--disable-updater',
'--disable-missing-files-check',
'--minimize-to-tray'
];
/** Which video encoder the latest OBS log shows starting, if any. */
export function encoderFromLog(log: string): 'amd' | 'x264' | 'other' | null {
let latest: { index: number; kind: 'amd' | 'x264' | 'other' } | null = null;
const patterns: [RegExp, 'amd' | 'x264' | 'other'][] = [
[/\[(texture|fallback)-amf-(h264|h265|av1): /g, 'amd'],
[/\[x264 encoder: /g, 'x264'],
[/\[(obs_nvenc|jim-nvenc|NVENC|qsv|obs-qsv11)[^\]]*: /gi, 'other']
];
for (const [pattern, kind] of patterns)
for (const match of log.matchAll(pattern))
if (!latest || match.index! > latest.index) latest = { index: match.index!, kind };
return latest?.kind ?? null;
}
export async function latestObsLog(configDir: string) {
const dir = join(configDir, 'logs');
const names = (await readdir(dir).catch(() => [] as string[])).filter((name) =>
name.endsWith('.txt')
);
names.sort();
const latest = names.at(-1);
return latest ? readFile(join(dir, latest), 'utf8').catch(() => '') : '';
}

View File

@@ -0,0 +1,382 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile, mkdir, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { Obs, obsAuthentication } from '../obs';
import { ObsScene, windowSetting, type SceneTarget } from './obs-control';
import { encoderFromLog, parseIni, setIni, stringifyIni, writeObsSettings } from './obs-files';
interface Item {
sceneItemId: number;
sourceName: string;
sceneItemIndex: number;
sceneItemEnabled: boolean;
transform: Record<string, unknown>;
}
/** Just enough obs-websocket 5 for the requests ObsScene makes, with an in-memory scene. */
function fakeObs(password: string, options: { ignore?: string[] } = {}) {
const model = {
scenes: ['Scene'],
program: 'Scene',
inputs: new Map<string, { kind: string; settings: Record<string, unknown> }>([
['Desktop Audio', { kind: 'wasapi_output_capture', settings: {} }]
]),
items: new Map<string, Item[]>([['Scene', []]]),
muted: new Set<string>(),
service: {
streamServiceType: 'rtmp_common',
streamServiceSettings: {} as Record<string, unknown>
},
sources: new Map<string, { width: number; height: number }>(),
requests: [] as string[],
nextId: 1
};
const salt = 'salt';
const challenge = 'challenge';
const handle = (type: string, data: Record<string, unknown>): Record<string, unknown> | null => {
const sceneItems = (scene: string) => model.items.get(scene) ?? [];
switch (type) {
case 'GetSceneList':
return {
scenes: model.scenes.map((sceneName) => ({ sceneName })),
currentProgramSceneName: model.program
};
case 'CreateScene':
model.scenes.push(String(data.sceneName));
model.items.set(String(data.sceneName), []);
return {};
case 'SetCurrentProgramScene':
model.program = String(data.sceneName);
return {};
case 'GetInputList':
return { inputs: [...model.inputs.keys()].map((inputName) => ({ inputName })) };
case 'CreateInput': {
const name = String(data.inputName);
model.inputs.set(name, {
kind: String(data.inputKind),
settings: { ...(data.inputSettings as object) }
});
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: name,
sceneItemIndex: items.length,
sceneItemEnabled: data.sceneItemEnabled !== false,
transform: {}
});
return {};
}
case 'GetInputSettings': {
const input = model.inputs.get(String(data.inputName))!;
return { inputKind: input.kind, inputSettings: input.settings };
}
case 'SetInputSettings':
Object.assign(model.inputs.get(String(data.inputName))!.settings, data.inputSettings);
return {};
case 'GetSceneItemList':
return {
sceneItems: sceneItems(String(data.sceneName)).map((item) => ({
sceneItemId: item.sceneItemId,
sourceName: item.sourceName,
sceneItemIndex: item.sceneItemIndex,
sceneItemEnabled: item.sceneItemEnabled
}))
};
case 'CreateSceneItem': {
const items = sceneItems(String(data.sceneName));
items.push({
sceneItemId: model.nextId++,
sourceName: String(data.sourceName),
sceneItemIndex: items.length,
sceneItemEnabled: true,
transform: {}
});
return {};
}
case 'SetSceneItemIndex': {
const items = sceneItems(String(data.sceneName));
const item = items.find((each) => each.sceneItemId === data.sceneItemId)!;
items.splice(items.indexOf(item), 1);
items.splice(Number(data.sceneItemIndex), 0, item);
items.forEach((each, index) => (each.sceneItemIndex = index));
return {};
}
case 'SetSceneItemEnabled':
sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!.sceneItemEnabled = data.sceneItemEnabled === true;
return {};
case 'GetSceneItemTransform': {
const item = sceneItems(String(data.sceneName)).find(
(each) => each.sceneItemId === data.sceneItemId
)!;
const size = model.sources.get(item.sourceName) ?? { width: 0, height: 0 };
return {
sceneItemTransform: {
sourceWidth: size.width,
sourceHeight: size.height,
cropBottom: 0,
boundsType: 'OBS_BOUNDS_NONE',
boundsWidth: 0,
boundsHeight: 0,
...item.transform
}
};
}
case 'SetSceneItemTransform':
Object.assign(
sceneItems(String(data.sceneName)).find((each) => each.sceneItemId === data.sceneItemId)!
.transform,
data.sceneItemTransform
);
return {};
case 'GetSpecialInputs':
return { desktop1: 'Desktop Audio', desktop2: null, mic1: null };
case 'SetInputMute':
model.muted.add(String(data.inputName));
return {};
case 'GetStreamServiceSettings':
return model.service;
case 'SetStreamServiceSettings':
model.service = data as typeof model.service;
return {};
default:
return null;
}
};
const server: Server<{ identified: boolean }> = Bun.serve<{ identified: boolean }>({
port: 0,
fetch(request, server) {
return server.upgrade(request, { data: { identified: false } })
? undefined
: new Response('no');
},
websocket: {
open(ws) {
ws.send(
JSON.stringify({ op: 0, d: { rpcVersion: 1, authentication: { salt, challenge } } })
);
},
message(ws, raw) {
const { op, d } = JSON.parse(String(raw));
if (op === 1) {
if (d.authentication !== obsAuthentication(password, salt, challenge)) {
ws.close(4009, 'auth');
return;
}
ws.data.identified = true;
ws.send(JSON.stringify({ op: 2, d: { negotiatedRpcVersion: 1 } }));
return;
}
if (op !== 6 || !ws.data.identified) return;
model.requests.push(d.requestType);
if (options.ignore?.includes(d.requestType)) return;
const result = handle(d.requestType, d.requestData ?? {});
ws.send(
JSON.stringify({
op: 7,
d: {
requestType: d.requestType,
requestId: d.requestId,
requestStatus: result
? { result: true, code: 100 }
: { result: false, code: 204, comment: 'unknown' },
responseData: result ?? undefined
}
})
);
}
}
});
return { server, model, url: `ws://127.0.0.1:${server.port}` };
}
const servers: Server<unknown>[] = [];
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
});
const target = (overrides: Partial<SceneTarget> = {}): SceneTarget => ({
canvas: { width: 1920, height: 1080 },
fps: 60,
overlayUrl: 'https://esports.vash.software/broadcast/abc/overlay',
capture: { title: 'Vash osu!tourney (prod)', className: 'WindowsForms10', exe: 'osu!.exe' },
showGameplay: false,
ingest: { server: 'rtmp://127.0.0.1:47103/live', key: 'k' },
...overrides
});
describe('Obs client', () => {
test('reports a wrong websocket password clearly', async () => {
const fake = fakeObs('right');
servers.push(fake.server);
const obs = new Obs(fake.url, 'wrong', 2000);
await expect(obs.connect()).rejects.toThrow('OBS rejected the websocket password');
});
test('connected means authenticated, and a hung request times out', async () => {
const fake = fakeObs('pw', { ignore: ['GetStreamStatus'] });
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw', 300);
expect(obs.connected).toBe(false);
await obs.connect();
expect(obs.connected).toBe(true);
await expect(obs.request('GetStreamStatus')).rejects.toThrow('OBS GetStreamStatus timed out');
await Bun.sleep(400);
obs.close();
expect(obs.connected).toBe(false);
});
});
describe('ObsScene', () => {
test('builds the scene once, keeps the overlay on top, and changes nothing on a second pass', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
const items = fake.model.items.get('Vash')!;
expect(fake.model.program).toBe('Vash');
expect(items.map((item) => item.sourceName)).toEqual(['Gameplay', 'Overlay']);
expect(items[0].sceneItemEnabled).toBe(false);
expect(fake.model.inputs.get('Gameplay')!.settings).toMatchObject({
window: 'Vash osu!tourney (prod):WindowsForms10:osu!.exe',
priority: 1,
capture_audio: true
});
expect(fake.model.inputs.get('Overlay')!.settings).toMatchObject({
width: 1920,
height: 1080,
fps: 60
});
expect(fake.model.muted.has('Desktop Audio')).toBe(true);
expect(fake.model.service.streamServiceSettings).toMatchObject({
server: 'rtmp://127.0.0.1:47103/live',
key: 'k'
});
fake.model.requests.length = 0;
await scene.reconcile(target());
expect(fake.model.requests.filter((type) => /^(Create|Set|Remove)/.test(type))).toEqual([]);
// Someone reorders the scene: a changed target re-checks everything.
scene.reset();
const overlay = items.find((item) => item.sourceName === 'Overlay')!;
items.splice(items.indexOf(overlay), 1);
items.unshift(overlay);
items.forEach((item, index) => (item.sceneItemIndex = index));
await scene.reconcile(target());
expect(fake.model.items.get('Vash')!.at(-1)!.sourceName).toBe('Overlay');
obs.close();
});
test('crops the control panel off the capture and shows it only while spectating', async () => {
const fake = fakeObs('pw');
servers.push(fake.server);
const obs = new Obs(fake.url, 'pw');
await obs.connect();
const scene = new ObsScene(obs, () => {});
await scene.reconcile(target());
// At 125 % display scaling the 1280×920 window captures as 1600×1150.
fake.model.sources.set('Gameplay', { width: 1600, height: 1150 });
await scene.reconcile(target({ showGameplay: true }));
const gameplay = fake.model.items.get('Vash')!.find((item) => item.sourceName === 'Gameplay')!;
expect(gameplay.sceneItemEnabled).toBe(true);
expect(gameplay.transform).toMatchObject({
cropBottom: 250,
boundsType: 'OBS_BOUNDS_SCALE_INNER',
boundsWidth: 1920,
boundsHeight: 1080
});
await scene.reconcile(target({ showGameplay: false }));
expect(gameplay.sceneItemEnabled).toBe(false);
obs.close();
});
});
describe('OBS files', () => {
let dir: string;
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
test('escapes window settings like libobs', () => {
expect(windowSetting({ title: 'a:b#c', className: 'X', exe: 'osu!.exe' })).toBe(
'a#3Ab#22c:X:osu!.exe'
);
});
test('ini round trip keeps unknown keys', () => {
const ini = parseIni('\uFEFF[General]\r\nName=Old\r\nKeep=1\r\n[Video]\r\nBaseCX=1280\r\n');
setIni(ini, { General: { Name: 'Vash' }, Audio: { SampleRate: 48000 } });
expect(stringifyIni(ini)).toBe(
'[General]\r\nName=Vash\r\nKeep=1\r\n\r\n[Video]\r\nBaseCX=1280\r\n\r\n[Audio]\r\nSampleRate=48000\r\n'
);
});
test('writes profile, websocket and first-run settings, and clears the crash sentinel', async () => {
dir = await mkdtemp(join(tmpdir(), 'vash-obs-'));
await mkdir(join(dir, '.sentinel'), { recursive: true });
await writeFile(join(dir, '.sentinel', 'run_123'), '');
await mkdir(join(dir, 'basic', 'profiles', 'Vash'), { recursive: true });
await writeFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), '[Hotkeys]\r\nX=1\r\n');
await writeObsSettings(dir, {
websocketPort: 47101,
websocketPassword: 'pw',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
encoder: 'amd',
ingestUrl: 'rtmp://127.0.0.1:47103/live',
ingestKey: 'key'
});
const profile = parseIni(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'basic.ini'), 'utf8')
);
expect(profile.get('SimpleOutput')?.get('StreamEncoder')).toBe('amd');
expect(profile.get('Video')?.get('FPSInt')).toBe('60');
expect(profile.get('Hotkeys')?.get('X')).toBe('1');
const user = parseIni(await readFile(join(dir, 'user.ini'), 'utf8'));
expect(user.get('General')?.get('FirstRun')).toBe('true');
expect(user.get('Basic')?.get('Profile')).toBe('Vash');
const websocket = JSON.parse(
await readFile(join(dir, 'plugin_config', 'obs-websocket', 'config.json'), 'utf8')
);
expect(websocket).toMatchObject({
server_enabled: true,
server_port: 47101,
auth_required: true,
server_password: 'pw',
first_load: false
});
const service = JSON.parse(
await readFile(join(dir, 'basic', 'profiles', 'Vash', 'service.json'), 'utf8')
);
expect(service).toEqual({
type: 'rtmp_custom',
settings: {
server: 'rtmp://127.0.0.1:47103/live',
key: 'key',
use_auth: false,
bwtest: false
}
});
expect(await readdir(join(dir, '.sentinel'))).toEqual([]);
});
test('reads which encoder OBS started last from its log', () => {
expect(
encoderFromLog("12:00:00.000: [x264 encoder: 'simple_video_stream'] preset: veryfast")
).toBe('x264');
expect(
encoderFromLog(
"[x264 encoder: 'simple_video_stream'] preset: veryfast\n[texture-amf-h264: 'simple_video_stream'] settings:"
)
).toBe('amd');
expect(encoderFromLog('no encoders here')).toBeNull();
});
});

89
streamer/windows/proxy.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Dev and staging sit behind Cloudflare Access. The worker can send an
* Access service token with its own requests, but OBS's browser source
* cannot, so it loads the overlay through this local proxy, which adds the
* token to every request and websocket. Production needs none of this.
*/
import type { Server, ServerWebSocket } from 'bun';
interface Tunnel {
target: string;
upstream: WebSocket | null;
queue: (string | Uint8Array)[];
}
/** No Referer, so the CDN's hotlink protection accepts images loaded from 127.0.0.1. */
export function withoutReferrer(html: string) {
const tag = '<meta name="referrer" content="no-referrer">';
return /<head[^>]*>/i.test(html)
? html.replace(/<head[^>]*>/i, (head) => `${head}${tag}`)
: tag + html;
}
export function startAccessProxy(options: {
port: number;
upstream: string;
headers: Record<string, string>;
log: (...parts: unknown[]) => void;
}): Server<Tunnel> {
const upstream = options.upstream.replace(/\/$/, '');
return Bun.serve<Tunnel>({
hostname: '127.0.0.1',
port: options.port,
async fetch(request, server) {
const url = new URL(request.url);
const target = `${upstream}${url.pathname}${url.search}`;
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
const data: Tunnel = { target: target.replace(/^http/, 'ws'), upstream: null, queue: [] };
return server.upgrade(request, { data })
? undefined
: new Response('Upgrade failed', { status: 400 });
}
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('accept-encoding');
for (const [key, value] of Object.entries(options.headers)) headers.set(key, value);
const response = await fetch(target, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(),
redirect: 'manual'
}).catch((err) => new Response(`Proxy: ${(err as Error).message}`, { status: 502 }));
const out = new Headers(response.headers);
out.delete('content-encoding');
out.delete('content-length');
if (out.get('content-type')?.includes('text/html'))
return new Response(withoutReferrer(await response.text()), {
status: response.status,
headers: out
});
return new Response(response.body, { status: response.status, headers: out });
},
websocket: {
open(ws: ServerWebSocket<Tunnel>) {
const socket = new WebSocket(ws.data.target, {
headers: options.headers
} as unknown as string[]);
socket.binaryType = 'arraybuffer';
ws.data.upstream = socket;
socket.onopen = () => {
for (const message of ws.data.queue.splice(0)) socket.send(message);
};
socket.onmessage = (event) =>
ws.send(
typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)
);
socket.onclose = () => ws.close();
socket.onerror = () => options.log('Overlay proxy: upstream websocket failed');
},
message(ws, message) {
const socket = ws.data.upstream;
if (socket?.readyState === WebSocket.OPEN) socket.send(message);
else ws.data.queue.push(message);
},
close(ws) {
ws.data.upstream?.close();
}
}
});
}

View File

@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
allocatePorts,
instanceDir,
layout,
listInstances,
portsForSlot,
saveConfig,
validateConfig,
validateServer,
type InstanceConfig
} from './config';
import { fetchPinned, osuCandidates, setSubsystem, subsystemOf, tosuEnv } from './install';
import { acquireLock, lockOwner, releaseLock } from './lock';
import { Supervised, type Child } from './supervise';
let home: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'vash-home-'));
process.env.VASH_BROADCAST_HOME = home;
});
afterEach(async () => {
delete process.env.VASH_BROADCAST_HOME;
await rm(home, { recursive: true, force: true });
});
const config = (name: string, slot: number): InstanceConfig => ({
version: 1,
name,
server: 'https://esports.vash.software',
channel: '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c',
token: 'vash_token',
access: null,
ports: portsForSlot(slot),
obsPassword: 'a',
ingestKey: 'b',
osuSource: null,
tourneyHeight: 720,
encoder: 'amd',
autostart: true
});
describe('instances', () => {
test('each instance gets its own folder, programs and ports', async () => {
await saveConfig(config('dev', 0));
const others = await listInstances();
const staging = {
...config('staging', 1),
ports: allocatePorts(others.map((each) => each.ports))
};
await saveConfig(staging);
const prod = {
...config('prod', 0),
ports: allocatePorts((await listInstances()).map((each) => each.ports))
};
const all = [config('dev', 0), staging, prod];
const ports = all.flatMap((each) => Object.values(each.ports));
expect(new Set(ports).size).toBe(ports.length);
const dirs = all.map((each) => layout(each.name));
for (const key of ['dir', 'obs', 'osu', 'tosu', 'ffmpeg', 'hls', 'log', 'state'] as const)
expect(new Set(dirs.map((paths) => paths[key])).size).toBe(3);
// Programs live under their instance folder, so stopping one never touches another.
for (const paths of dirs)
for (const key of ['obsExe', 'osuExe', 'tosuExe', 'ffmpeg'] as const)
expect(paths[key].startsWith(paths.dir)).toBe(true);
expect(layout('dev').lock).toBe(layout('prod').lock);
});
test('rejects bad names, servers and configs', () => {
expect(() => instanceDir('../prod')).toThrow();
expect(() => instanceDir('Prod')).toThrow();
expect(validateServer('https://esports.vash.software/broadcast')).toBe(
'https://esports.vash.software'
);
expect(() => validateServer('http://esports.vash.software')).toThrow('https');
expect(validateServer('http://127.0.0.1:3102')).toBe('http://127.0.0.1:3102');
expect(() => validateConfig({ ...config('dev', 0), channel: 'nope' })).toThrow('channel');
expect(() => validateConfig({ ...config('dev', 0), encoder: 'nvenc' })).toThrow('encoder');
});
});
describe('tourney lock', () => {
test('one instance at a time; a dead holder is replaced; only the holder releases it', async () => {
const path = join(home, 'tourney.lock');
expect(await acquireLock(path, 'prod', process.pid)).toEqual({ ok: true });
// Another live process (this test's parent stands in) cannot take it.
const other = await acquireLock(path, 'dev', process.ppid);
expect(other).toMatchObject({ ok: false, owner: { instance: 'prod' } });
await releaseLock(path, process.ppid);
expect(existsSync(path)).toBe(true);
await releaseLock(path, process.pid);
expect(existsSync(path)).toBe(false);
await writeFile(path, JSON.stringify({ instance: 'staging', pid: 2 ** 22 + 12345, at: '' }));
expect(await lockOwner(path)).toBeNull();
expect(await acquireLock(path, 'dev', process.pid)).toEqual({ ok: true });
});
});
describe('Supervised', () => {
function fakeChild(pid: number) {
let exit!: (code: number) => void;
const child: Child & { exit: (code: number) => void; killed: boolean } = {
pid,
exited: new Promise<number | null>((resolve) => (exit = resolve)),
killed: false,
kill() {
child.killed = true;
exit(1);
},
exit: (code) => exit(code)
};
return child;
}
test('restarts with growing delays, forgets failures after a stable run, and stays stopped', async () => {
let now = 0;
const children: ReturnType<typeof fakeChild>[] = [];
const lines: string[] = [];
const supervised = new Supervised(
'OBS',
() => {
const child = fakeChild(children.length + 1);
children.push(child);
return child;
},
{ log: (line) => lines.push(String(line)), now: () => now, stableMs: 60_000 }
);
await supervised.ensure();
expect(supervised.state).toBe('running');
children[0].exit(3);
await Bun.sleep(0);
expect(supervised.state).toBe('backoff');
expect(lines.at(-1)).toBe('OBS exited with 3; restarting in 2s');
now += 1000;
await supervised.ensure();
expect(children).toHaveLength(1);
now += 1000;
await supervised.ensure();
expect(children).toHaveLength(2);
children[1].exit(1);
await Bun.sleep(0);
expect(lines.at(-1)).toContain('restarting in 4s');
now += 4000;
await supervised.ensure();
now += 120_000;
children[2].exit(1);
await Bun.sleep(0);
expect(lines.at(-1)).toContain('restarting in 2s');
now += 2000;
await supervised.ensure();
await supervised.stop();
expect(children[3].killed).toBe(true);
expect(supervised.state).toBe('stopped');
now += 60_000;
expect(supervised.restarts).toBe(3);
});
});
describe('install', () => {
test('switches a PE executable between console and windowless', () => {
// MZ header pointing at a PE header with a PE32+ optional header.
const exe = new Uint8Array(512);
const view = new DataView(exe.buffer);
view.setUint16(0, 0x5a4d, true);
view.setUint32(0x3c, 0x80, true);
view.setUint32(0x80, 0x00004550, true);
view.setUint16(0x80 + 24, 0x20b, true);
view.setUint32(0x80 + 24 + 64, 0x1234, true);
view.setUint16(0x80 + 24 + 68, 3, true);
setSubsystem(exe, 2);
expect(subsystemOf(exe)).toBe(2);
expect(view.getUint32(0x80 + 24 + 64, true)).toBe(0);
expect(() => setSubsystem(new Uint8Array(512), 2)).toThrow('Not an MZ executable');
});
test('downloads only what matches the pinned checksum', async () => {
const body = new TextEncoder().encode('pinned release bytes');
const server = Bun.serve({ port: 0, fetch: () => new Response(body) });
const downloads = join(home, 'downloads');
const sha256 = createHash('sha256').update(body).digest('hex');
const url = `http://127.0.0.1:${server.port}/tool.zip`;
try {
const path = await fetchPinned(
{ name: 'tool', version: '1', url, sha256, bytes: body.length },
downloads,
() => {}
);
expect(path).toBe(join(downloads, 'tool.zip'));
await expect(
fetchPinned(
{
name: 'bad',
version: '1',
url: url.replace('tool', 'bad'),
sha256: '0'.repeat(64),
bytes: 0
},
downloads,
() => {}
)
).rejects.toThrow('did not match its pinned SHA-256');
expect(await readdir(downloads)).toEqual(['tool.zip']);
} finally {
server.stop(true);
}
});
test('tosu gets its port, no auto-update and no browser', () => {
const env = tosuEnv(47102);
expect(env).toContain('SERVER_PORT=47102');
expect(env).toContain('SERVER_IP=127.0.0.1');
expect(env).toContain('ENABLE_AUTOUPDATE=false');
expect(env).toContain('OPEN_DASHBOARD_ON_STARTUP=false');
});
test('looks for osu! where stable installs itself', () => {
expect(osuCandidates({ LOCALAPPDATA: 'C:\\Users\\stan\\AppData\\Local' })[0]).toBe(
join('C:\\Users\\stan\\AppData\\Local', 'osu!', 'osu!.exe')
);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Keeps one child program running: restarts it when it exits, waiting longer
* after each quick failure (2 s, 4 s, … up to a minute) and forgetting past
* failures once it has stayed up for a while.
*/
export interface Child {
readonly pid: number;
readonly exited: Promise<number | null>;
kill(): void;
}
export type SupervisedState = 'stopped' | 'starting' | 'running' | 'backoff';
export interface SupervisorOptions {
log: (...parts: unknown[]) => void;
now?: () => number;
/** A run at least this long resets the backoff. */
stableMs?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
export class Supervised {
state: SupervisedState = 'stopped';
child: Child | null = null;
restarts = 0;
failures = 0;
lastExit: string | null = null;
startedAt = 0;
private retryAt = 0;
private wanted = false;
private now: () => number;
constructor(
readonly name: string,
private launch: () => Child | Promise<Child>,
private options: SupervisorOptions
) {
this.now = options.now ?? Date.now;
}
get running() {
return this.state === 'running';
}
/** Starts the program if it should run and is not; call on every tick. */
async ensure() {
this.wanted = true;
if (this.state === 'running' || this.state === 'starting') return;
if (this.state === 'backoff' && this.now() < this.retryAt) return;
this.state = 'starting';
let child: Child;
try {
child = await this.launch();
} catch (err) {
this.onExit(`could not start: ${(err as Error).message}`);
return;
}
if (!this.wanted) {
child.kill();
this.state = 'stopped';
return;
}
this.child = child;
this.startedAt = this.now();
this.state = 'running';
void child.exited.then((code) => {
if (this.child !== child) return;
this.child = null;
this.onExit(`exited with ${code}`);
});
}
private onExit(reason: string) {
this.lastExit = reason;
if (!this.wanted) {
this.state = 'stopped';
return;
}
const ranFor = this.startedAt ? this.now() - this.startedAt : 0;
if (ranFor >= (this.options.stableMs ?? 5 * 60_000)) this.failures = 0;
const delay = Math.min(
this.options.maxDelayMs ?? 60_000,
(this.options.baseDelayMs ?? 2000) * 2 ** this.failures
);
this.failures++;
this.restarts++;
this.startedAt = 0;
this.retryAt = this.now() + delay;
this.state = 'backoff';
this.options.log(`${this.name} ${reason}; restarting in ${Math.round(delay / 1000)}s`);
}
/** Stops the program and keeps it stopped until the next ensure(). */
async stop(graceful?: (child: Child) => Promise<void>) {
this.wanted = false;
const child = this.child;
this.child = null;
this.state = 'stopped';
if (!child) return;
if (graceful) {
await graceful(child).catch(() => {});
const done = await Promise.race([
child.exited.then(() => true),
Bun.sleep(10_000).then(() => false)
]);
if (done) return;
}
child.kill();
await Promise.race([child.exited, Bun.sleep(5000)]);
}
status() {
return {
state: this.state,
pid: this.child?.pid ?? null,
restarts: this.restarts,
lastExit: this.lastExit,
upSec: this.state === 'running' ? Math.round((this.now() - this.startedAt) / 1000) : 0
};
}
}

132
streamer/windows/tasks.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* The scheduled task that starts an instance inside the signed-in Windows
* desktop. Programs started straight from an SSH session run in a session
* with no desktop, where OBS, osu! and window capture cannot work; a task
* with LogonType Interactive runs in the user's own desktop session instead,
* and keeps running when the SSH connection closes.
*/
export const taskName = (instance: string) => `Vash Broadcast (${instance})`;
/** Runs a PowerShell script with values passed as environment variables (no quoting problems). */
export async function powershell(script: string, env: Record<string, string> = {}) {
if (process.platform !== 'win32') throw new Error('Scheduled tasks exist only on Windows');
// -EncodedCommand (UTF-16LE, base64) sidesteps Windows command-line quoting entirely.
const encoded = Buffer.from(
`$ProgressPreference = 'SilentlyContinue'\n${script}`,
'utf16le'
).toString('base64');
const child = Bun.spawn(
[
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded
],
{ stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env }, windowsHide: true }
);
const [stdout, stderr, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited
]);
if (code !== 0)
throw new Error(
stderr.trim().split(/\r?\n/).slice(0, 3).join(' ') || `PowerShell exited with ${code}`
);
return stdout.trim();
}
export async function registerTask(options: {
instance: string;
exe: string;
cwd: string;
autostart: boolean;
}) {
await powershell(
`$ErrorActionPreference = 'Stop'
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$action = New-ScheduledTaskAction -Execute $env:VASH_EXE -Argument $env:VASH_ARGS -WorkingDirectory $env:VASH_CWD
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
# Priority 4 is normal; the default (7) would slow OBS and osu! down.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -StartWhenAvailable -Priority 4
$task = @{ Action = $action; Principal = $principal; Settings = $settings; Description = 'Vash broadcast worker. Manage it with vash-broadcast.exe.' }
if ($env:VASH_AUTOSTART -eq '1') { $task.Trigger = New-ScheduledTaskTrigger -AtLogOn -User $user }
$definition = New-ScheduledTask @task
Register-ScheduledTask -TaskName $env:VASH_TASK -InputObject $definition -Force | Out-Null`,
{
VASH_TASK: taskName(options.instance),
VASH_EXE: options.exe,
VASH_ARGS: `task --instance ${options.instance}`,
VASH_CWD: options.cwd,
VASH_AUTOSTART: options.autostart ? '1' : '0'
}
);
}
export async function runTask(instance: string) {
await powershell(`Start-ScheduledTask -TaskName $env:VASH_TASK`, {
VASH_TASK: taskName(instance)
});
}
export async function endTask(instance: string) {
await powershell(`Stop-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue`, {
VASH_TASK: taskName(instance)
}).catch(() => {});
}
export async function unregisterTask(instance: string) {
await powershell(
`Unregister-ScheduledTask -TaskName $env:VASH_TASK -Confirm:$false -ErrorAction SilentlyContinue`,
{ VASH_TASK: taskName(instance) }
).catch(() => {});
}
export interface TaskInfo {
exists: boolean;
state: string | null;
lastRunTime: string | null;
lastTaskResult: number | null;
autostart: boolean;
}
export async function taskInfo(instance: string): Promise<TaskInfo> {
const output = await powershell(
`$task = Get-ScheduledTask -TaskName $env:VASH_TASK -ErrorAction SilentlyContinue
if ($null -eq $task) { '{"exists":false}'; exit }
$info = $task | Get-ScheduledTaskInfo
@{ exists = $true; state = [string]$task.State; lastRunTime = if ($info.LastRunTime) { $info.LastRunTime.ToString('o') } else { $null }; lastTaskResult = [long]$info.LastTaskResult; autostart = @($task.Triggers).Count -gt 0 } | ConvertTo-Json -Compress`,
{ VASH_TASK: taskName(instance) }
);
const info = JSON.parse(output) as Partial<TaskInfo>;
return {
exists: !!info.exists,
state: info.state ?? null,
lastRunTime: info.lastRunTime ?? null,
lastTaskResult: info.lastTaskResult ?? null,
autostart: !!info.autostart
};
}
/** Who is signed in at the PC's own screen, if anyone. */
export async function consoleUser() {
const output = await powershell(`(Get-CimInstance Win32_ComputerSystem).UserName`).catch(
() => ''
);
return output || null;
}
export async function currentUser() {
return powershell(`[System.Security.Principal.WindowsIdentity]::GetCurrent().Name`);
}
export async function gpuNames() {
const output = await powershell(
`(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join "\`n"`
).catch(() => '');
return output.split(/\r?\n/).filter(Boolean);
}

View File

@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tournamentConfig } from '../tourney';
import type { Follow } from '../link';
import type { OcrLine } from './desktop';
import { FakeDesktop, fakeTosu, tourneyFrame, windowInfo } from './fakes';
import {
findRoomLine,
parseRoomName,
pickManagerWindow,
savedLogin,
teamsMatch,
TourneyController
} from './tourney';
const line = (text: string, y: number): OcrLine => ({ text, x: 10, y, width: 600, height: 30 });
describe('room names and matching', () => {
test('parses the acronym and both team names', () => {
expect(parseRoomName('VASH: (Red Team) vs (Blue: Team)')).toEqual({
acronym: 'VASH',
teams: ['Red Team', 'Blue: Team']
});
expect(parseRoomName('not a tourney room')).toBeNull();
expect(parseRoomName(null)).toBeNull();
});
test('finds the room by its lobby id, even with OCR look-alike letters', () => {
const lines = [
line('118000001 : @VASH: (Alpha) vs (Beta) () 0/16', 0),
line('11800OO42 : @VASH: (Alpha) vs (Beta) () 2/16', 40)
];
const match = findRoomLine(lines, 118000042, 'VASH: (Alpha) vs (Beta)');
expect(match).toMatchObject({ found: true, by: 'lobby id', line: { y: 40 } });
});
test('falls back to the room name only when exactly one room has it', () => {
const one = [line('VASH: (Other) vs (Team)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
expect(findRoomLine(one, 5, 'VASH: (Alpha) vs (Beta)')).toMatchObject({
found: true,
by: 'room name',
line: { y: 40 }
});
const two = [line('VASH: (Alpha) vs (Beta)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
const ambiguous = findRoomLine(two, 5, 'VASH: (Alpha) vs (Beta)');
expect(ambiguous.found).toBe(false);
expect(ambiguous.found || ambiguous.reason).toContain('2 rooms are named');
expect(
findRoomLine([line('Make a multiplayer room with format:', 0)], 5, 'VASH: (A) vs (B)')
).toMatchObject({
found: false,
reason: 'the room is not listed yet'
});
});
test('confirms the room from the team names tosu reads', () => {
expect(teamsMatch({ left: 'Alpha', right: 'Beta' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'beta', right: 'ALPHA' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'Alpha', right: 'Gamma' }, 'VASH: (Alpha) vs (Beta)')).toBe(false);
expect(teamsMatch({ left: '', right: '' }, 'VASH: ( ) vs ( )')).toBe(false);
expect(teamsMatch(null, 'VASH: (Alpha) vs (Beta)')).toBe(false);
});
test('picks the manager window (control panel below 16:9), not a spectator window', () => {
const spectator = windowInfo({ hwnd: 1, clientWidth: 640, clientHeight: 360 });
const manager = windowInfo({ hwnd: 2, clientWidth: 1280, clientHeight: 920 });
const hidden = windowInfo({ hwnd: 3, clientWidth: 1920, clientHeight: 1300, visible: false });
expect(pickManagerWindow([spectator, hidden, manager])?.hwnd).toBe(2);
expect(pickManagerWindow([spectator])).toBeNull();
});
test('reads a remembered login from osu!.<user>.cfg', () => {
expect(savedLogin('Username = spectator\r\nPassword = AQAAANCM\r\nSavePassword = 1')).toEqual({
username: 'spectator',
saved: true
});
expect(savedLogin('Username = spectator\nPassword = \n')).toEqual({
username: 'spectator',
saved: false
});
expect(savedLogin('')).toEqual({ username: null, saved: false });
});
test('tournament.cfg keeps other keys and sets ours', () => {
expect(
tournamentConfig('privateserver = 0\r\nTeamSize = 4\r\nacronym = OWC\r\n', {
teamSize: 2,
height: 720,
acronym: 'VASH'
})
).toBe('privateserver = 0\nTeamSize = 2\nacronym = VASH\nHeight = 720\n');
});
});
describe('TourneyController', () => {
let dir: string;
let desktop: FakeDesktop;
let tosu: ReturnType<typeof fakeTosu>;
let launches: number;
let tourney: TourneyController;
let now: number;
const advance = (ms: number) => {
now += ms;
setSystemTime(new Date(now));
};
const follow = (overrides: Partial<Follow> = {}): Follow => ({
scene: 'gameplay',
matchId: 'm1',
lobbyId: 118000042,
roomName: 'VASH: (Alpha) vs (Beta)',
teamSize: 2,
title: 'Alpha vs Beta',
...overrides
});
beforeEach(async () => {
now = Date.parse('2026-09-29T12:00:00Z');
setSystemTime(new Date(now));
dir = await mkdtemp(join(tmpdir(), 'vash-tourney-'));
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = spectator\nPassword = secret\n');
desktop = new FakeDesktop();
tosu = fakeTosu();
launches = 0;
tourney = new TourneyController({
osuDir: dir,
snapshots: join(dir, 'snapshots'),
height: 720,
windowTitle: 'Vash osu!tourney (test)',
desktop,
tosu,
log: () => {},
launch: () => {
launches++;
desktop.running.push({ pid: 10 + launches, path: join(dir, 'osu!.exe') });
}
});
});
afterEach(async () => {
setSystemTime();
await rm(dir, { recursive: true, force: true });
});
test('launches with the match team size, selects the room by lobby id and confirms it', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('starting');
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toBe(
'TeamSize = 2\nacronym = VASH\nHeight = 720\n'
);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('title')[0].args).toEqual([100, 'Vash osu!tourney (test)']);
expect(desktop.called('click')).toHaveLength(0);
advance(9000);
desktop.ocrLines = [
line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20),
line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)
];
await tourney.tick(desktop.sessionInfo);
// Panel at client (8, 31 + 720), captured at 2.5×: the second line's start.
expect(desktop.called('click')[0].args).toEqual([52, 781]);
expect(tourney.state).toBe('selecting');
expect(tourney.verified).toBe(false);
tosu.frame = tourneyFrame('Alpha', 'Beta');
advance(2000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('spectating');
expect(tourney.verified).toBe(true);
});
test('restarts osu!tourney for a different team size, and not for an idle channel', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
tourney.follow(
follow({ matchId: null, lobbyId: null, roomName: null, teamSize: null, title: null })
);
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('idle');
tourney.follow(follow({ lobbyId: 5, teamSize: 1, roomName: 'VASH: (C) vs (D)' }));
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('close')).toHaveLength(1);
expect(launches).toBe(2);
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toContain('TeamSize = 1');
});
test('does not click while Windows is locked', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.sessionInfo = { ...desktop.sessionInfo, inputDesktop: 'Winlogon' };
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('desktop-locked');
expect(desktop.called('click')).toHaveLength(0);
});
test('selects again when tosu shows a different room after the click', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(1);
tosu.frame = tourneyFrame('Other', 'Team');
advance(5000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('selecting');
advance(13_000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.verified).toBe(false);
advance(11_000);
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(2);
});
test('waits for the room when it is not listed, and never clicks another room', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20)];
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('waiting-for-lobby');
expect(tourney.detail).toContain('1 room listed');
expect(desktop.called('click')).toHaveLength(0);
});
test('asks for the one-time sign-in instead of launching without a saved login', async () => {
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = \n');
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('login-required');
expect(launches).toBe(0);
});
});

437
streamer/windows/tourney.ts Normal file
View File

@@ -0,0 +1,437 @@
/**
* osu!tourney on Windows: keeps the instance's own tourney client running
* with the right tournament.cfg, finds its window, and spectates the lobby
* Vash asks for, without anyone clicking.
*
* Selecting a room: osu!tourney lists rooms named "<acronym>: (A) vs (B)" in
* its control panel as "<match id> : <room name> …". There is no command or
* IPC for choosing one, so the worker reads the panel with Windows' own OCR,
* clicks the line with the lobby's match id (or, failing that, the only line
* with both team names), and then checks tosu: osu!tourney's current team
* names must be the ones in the requested room before it counts as selected.
*/
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { plain, tournamentConfig } from '../tourney';
import type { Follow } from '../link';
import type { TosuFeed } from '../tosu';
import type { Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop';
// ── Pure helpers (tested) ────────────────────────────────────────────────
/** "VASH: (Red) vs (Blue)" → acronym and team names. */
export function parseRoomName(roomName: string | null) {
const match = roomName ? /^(.+?): \((.*)\) vs \((.*)\)\s*$/.exec(roomName) : null;
if (!match || !match[1].trim()) return null;
return { acronym: match[1].trim(), teams: [match[2], match[3]] as [string, string] };
}
/** The streamable area is 16:9 across the window's width; the control panel sits below it. */
export function gameplayHeight(clientWidth: number) {
return Math.round((clientWidth * 9) / 16);
}
/** The manager window is taller than 16:9 by its control panel; spectator windows are not. */
export function pickManagerWindow(windows: WindowInfo[]): WindowInfo | null {
const candidates = windows.filter(
(window) =>
window.visible &&
window.clientWidth >= 400 &&
window.clientHeight > gameplayHeight(window.clientWidth) + 60
);
candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
return candidates[0] ?? null;
}
/** The biggest visible window of the osu! installation (for sign-in, when there is no panel). */
export function pickMainWindow(windows: WindowInfo[]): WindowInfo | null {
const visible = windows.filter((window) => window.visible && window.clientWidth > 0);
visible.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
return visible[0] ?? null;
}
/** OCR misreads digits as look-alike letters; read a leading "match id :" generously. */
function leadingNumber(text: string) {
const match = /^\s*([0-9OoIl|SB]{5,})\s*[:;.]/.exec(text);
if (!match) return null;
return match[1]
.replace(/[Oo]/g, '0')
.replace(/[Il|]/g, '1')
.replace(/S/g, '5')
.replace(/B/g, '8');
}
export type RoomMatch =
| { found: true; line: OcrLine; by: 'lobby id' | 'room name' }
| { found: false; reason: string; rooms: number };
/**
* Finds the requested room in the control panel's OCR lines. The lobby's
* match id is the reliable key; the room name is used only when exactly one
* listed room has both team names, never "the first room".
*/
export function findRoomLine(
lines: OcrLine[],
lobbyId: number | null,
roomName: string | null
): RoomMatch {
const rooms = lines.filter((line) => /\bvs\b/i.test(line.text) || leadingNumber(line.text));
if (lobbyId != null) {
const id = String(lobbyId);
const byId = lines.find(
(line) => leadingNumber(line.text) === id || line.text.replace(/\s+/g, '').includes(`${id}:`)
);
if (byId) return { found: true, line: byId, by: 'lobby id' };
}
const room = parseRoomName(roomName);
if (!room) return { found: false, reason: 'no room name to look for', rooms: rooms.length };
const wanted = plain(`${room.acronym}${room.teams[0]}vs${room.teams[1]}`);
const byName = lines.filter((line) => plain(line.text).includes(wanted));
if (byName.length === 1) return { found: true, line: byName[0], by: 'room name' };
if (byName.length > 1)
return {
found: false,
reason: `${byName.length} rooms are named ${roomName}; waiting for the lobby id to be readable`,
rooms: rooms.length
};
return { found: false, reason: 'the room is not listed yet', rooms: rooms.length };
}
/** Whether osu!tourney (via tosu) is spectating a room with these team names. */
export function teamsMatch(
actual: { left: string; right: string } | null | undefined,
roomName: string | null
) {
const room = parseRoomName(roomName);
if (!room || !actual) return false;
const [a, b] = room.teams.map(plain);
const left = plain(actual.left);
const right = plain(actual.right);
if (!left && !right) return false;
return (left === a && right === b) || (left === b && right === a);
}
/** osu! stable remembers a login in osu!.<Windows user>.cfg. */
export function savedLogin(cfg: string) {
const value = (key: string) =>
new RegExp(`^\\s*${key}\\s*=\\s*(.*?)\\s*$`, 'mi').exec(cfg)?.[1] ?? '';
const username = value('Username');
return { username: username || null, saved: !!username && !!value('Password') };
}
export async function readSavedLogin(osuDir: string) {
const files = (await readdir(osuDir).catch(() => [] as string[])).filter((name) =>
/^osu!\..+\.cfg$/i.test(name)
);
for (const name of files) {
const login = savedLogin(await readFile(join(osuDir, name), 'utf8').catch(() => ''));
if (login.saved) return login;
}
return { username: null, saved: false };
}
// ── The controller ──────────────────────────────────────────────────────
export type TourneyState =
| 'stopped'
| 'login-required'
| 'starting'
| 'desktop-locked'
| 'idle'
| 'waiting-for-lobby'
| 'selecting'
| 'spectating'
| 'error';
export interface TourneyOptions {
osuDir: string;
snapshots: string;
height: number;
/** Shown in the window title so OBS captures exactly this window. */
windowTitle: string;
desktop: Desktop;
tosu: TosuFeed;
log: (...parts: unknown[]) => void;
/** Starts osu!.exe; defaults to Bun.spawn. */
launch?: (exe: string, cwd: string) => void;
}
export class TourneyController {
state: TourneyState = 'stopped';
detail: string | null = null;
window: WindowInfo | null = null;
/** The room was clicked and tosu confirmed it (or tosu is down and could not). */
verified = false;
private want: Follow | null = null;
private launchedWith: string | null = null;
private launchedAt = 0;
private windowSeenAt = 0;
private selected: { lobbyId: number; at: number } | null = null;
private mismatchSince = 0;
private nextAttempt = 0;
private failures = 0;
private busy = false;
/** Kept between matches, so an idle channel does not restart osu!tourney. */
private teamSize = 1;
private acronym = 'VASH';
constructor(private options: TourneyOptions) {}
private get log() {
return this.options.log;
}
/** What Vash wants shown; a new lobby or team size takes effect on the next tick. */
follow(follow: Follow) {
const lobbyChanged = follow.lobbyId !== this.want?.lobbyId;
this.want = follow;
if (follow.teamSize) this.teamSize = Math.min(8, Math.max(1, follow.teamSize));
const room = parseRoomName(follow.roomName);
if (room) this.acronym = room.acronym;
if (lobbyChanged) {
this.selected = null;
this.verified = false;
this.failures = 0;
this.nextAttempt = 0;
}
}
/** The tournament.cfg the wanted match needs. */
private desiredConfig() {
return { teamSize: this.teamSize, acronym: this.acronym, height: this.options.height };
}
private set(state: TourneyState, detail: string | null = null) {
if (state !== this.state || detail !== this.detail) {
if (state !== this.state) this.log(`osu!tourney: ${state}${detail ? ` (${detail})` : ''}`);
this.state = state;
this.detail = detail;
}
}
/** Advances launch, window discovery and room selection. Call every couple of seconds. */
async tick(session: SessionInfo | null) {
if (this.busy) return;
this.busy = true;
try {
await this.step(session);
} catch (err) {
this.set('error', (err as Error).message);
} finally {
this.busy = false;
}
}
private async step(session: SessionInfo | null) {
const { desktop, osuDir } = this.options;
const login = await readSavedLogin(osuDir);
if (!login.saved) {
this.set('login-required', 'Run vash-broadcast login once to sign in the spectator account');
return;
}
const desired = this.desiredConfig();
const key = JSON.stringify(desired);
const processes = await desktop.processes(osuDir);
if (!processes.length || this.launchedWith !== key) {
if (processes.length) {
this.log(
`Restarting osu!tourney for ${desired.teamSize}v${desired.teamSize} (${desired.acronym})`
);
await this.stopProcesses();
}
await this.writeConfig(desired);
this.launch();
this.launchedWith = key;
this.launchedAt = Date.now();
this.window = null;
this.windowSeenAt = 0;
this.selected = null;
this.verified = false;
this.set('starting', 'launching osu!tourney');
return;
}
const windows = await desktop.windows(osuDir);
const manager = pickManagerWindow(windows);
if (!manager) {
// A minimized manager has no client area to capture; bring it back. Only the window
// already known as the manager (it carries our title), never a spectator window.
const minimized = windows.find(
(window) => window.minimized && window.title === this.options.windowTitle
);
if (minimized) {
this.log('osu!tourney was minimized; restoring it');
await desktop.focus(minimized.hwnd);
return;
}
this.window = null;
const waited = Date.now() - this.launchedAt;
// A first start may update osu! itself for a few minutes.
if (waited > 5 * 60_000) {
this.log('osu!tourney window did not appear; restarting it');
await this.stopProcesses();
this.launchedWith = null;
this.set('error', 'osu!tourney did not open its window in 5 minutes');
} else this.set('starting', 'waiting for the osu!tourney window');
return;
}
if (!this.windowSeenAt) this.windowSeenAt = Date.now();
this.window = manager;
if (manager.title !== this.options.windowTitle)
await desktop.setTitle(manager.hwnd, this.options.windowTitle);
if (session && (manager.x < -8 || manager.y < -8 || manager.x + 100 > session.screenWidth)) {
await desktop.move(manager.hwnd, 0, 0);
this.window = (await desktop.window(manager.hwnd)) ?? manager;
}
const want = this.want;
if (!want?.lobbyId || !want.roomName) {
this.verified = false;
this.set('idle', want?.matchId ? 'the match has no osu! lobby to spectate' : null);
return;
}
const frame = this.options.tosu.fresh();
if (teamsMatch(frame?.teams, want.roomName)) {
this.mismatchSince = 0;
if (!this.verified)
this.log(`Spectating ${want.roomName} (mp/${want.lobbyId}), confirmed by tosu`);
this.selected = { lobbyId: want.lobbyId, at: this.selected?.at ?? Date.now() };
this.verified = true;
this.failures = 0;
this.set('spectating', null);
return;
}
if (this.selected?.lobbyId === want.lobbyId) {
if (!frame) {
// Nothing can confirm the click while tosu is down; say so rather than claim success.
this.verified = true;
this.set('spectating', 'room selected; tosu is not answering, so it is unconfirmed');
return;
}
this.mismatchSince ||= Date.now();
if (Date.now() - this.mismatchSince < 12_000) {
this.set('selecting', `waiting for osu!tourney to join ${want.roomName}`);
return;
}
this.log(
`osu!tourney shows "${frame.teams.left}" vs "${frame.teams.right}", not ${want.roomName}; selecting again`
);
this.selected = null;
this.verified = false;
this.failures++;
}
this.verified = false;
// osu!tourney needs a moment after its window appears before the room list fills.
if (Date.now() - this.windowSeenAt < 8000 || Date.now() < this.nextAttempt) return;
await this.select(want, manager, session);
}
private async select(want: Follow, manager: WindowInfo, session: SessionInfo | null) {
const { desktop, snapshots } = this.options;
if (session && session.inputDesktop !== 'Default') {
this.set(
'desktop-locked',
'Windows is locked; unlock it (or disable the lock screen) so the room can be selected'
);
this.nextAttempt = Date.now() + 5000;
return;
}
this.nextAttempt = Date.now() + Math.min(30_000, 5000 * 2 ** Math.min(this.failures, 3));
if (!(await desktop.focus(manager.hwnd))) {
this.set('selecting', 'could not bring osu!tourney to the front');
this.failures++;
return;
}
const window = (await desktop.window(manager.hwnd)) ?? manager;
const top = gameplayHeight(window.clientWidth);
const region = {
x: window.clientX,
y: window.clientY + top,
width: Math.round(window.clientWidth * 0.7),
height: window.clientHeight - top
};
const limit = await desktop
.ocrReady()
.then((ready) => ready.maxDimension)
.catch(() => 2600);
const scale = Math.max(1, Math.min(2.5, limit / region.width));
await mkdir(snapshots, { recursive: true });
const image = join(snapshots, 'tourney-panel.png');
await desktop.capture({ ...region, path: image, scale, invert: true });
const lines = await desktop.ocr(image);
await writeFile(
join(snapshots, 'tourney-panel.json'),
JSON.stringify({ at: new Date().toISOString(), scale, region, lines }, null, '\t')
);
const match = findRoomLine(lines, want.lobbyId, want.roomName);
if (!match.found) {
this.set(
'waiting-for-lobby',
`${match.reason} (${match.rooms} room${match.rooms === 1 ? '' : 's'} listed)`
);
return;
}
const x = Math.round(region.x + (match.line.x + Math.min(match.line.width, 200) / 2) / scale);
const y = Math.round(region.y + (match.line.y + match.line.height / 2) / scale);
this.log(`Clicking ${want.roomName} (mp/${want.lobbyId}), found by ${match.by} at ${x},${y}`);
if (!(await desktop.click(x, y))) {
this.set('selecting', 'Windows refused the click');
this.failures++;
return;
}
this.selected = { lobbyId: want.lobbyId!, at: Date.now() };
this.mismatchSince = Date.now();
this.set('selecting', `joining ${want.roomName}`);
}
private async writeConfig(desired: { teamSize: number; acronym: string; height: number }) {
const path = join(this.options.osuDir, 'tournament.cfg');
const existing = await readFile(path, 'utf8').catch(() => '');
await writeFile(path, tournamentConfig(existing, desired));
}
private launch() {
const exe = join(this.options.osuDir, 'osu!.exe');
if (this.options.launch) return this.options.launch(exe, this.options.osuDir);
Bun.spawn([exe], { cwd: this.options.osuDir, stdout: 'ignore', stderr: 'ignore' });
}
/** Closes every osu! process of this installation (manager and spectator clients). */
async stopProcesses() {
const { desktop, osuDir } = this.options;
const processes = await desktop.processes(osuDir);
for (const process of processes) await desktop.close(process.pid).catch(() => 0);
for (let waited = 0; waited < 8000; waited += 500) {
if (!(await desktop.processes(osuDir)).length) return;
await Bun.sleep(500);
}
await desktop.kill((await desktop.processes(osuDir)).map((process) => process.pid));
}
async stop() {
this.launchedWith = null;
this.window = null;
this.verified = false;
await this.stopProcesses();
this.set('stopped');
}
}
// ── One-time sign-in ────────────────────────────────────────────────────
/**
* Moves tournament.cfg aside so osu! starts normally (for signing in), and
* puts it back afterwards. Returns a function that restores it.
*/
export async function normalModeFor(osuDir: string) {
const cfg = join(osuDir, 'tournament.cfg');
const aside = join(osuDir, 'tournament.cfg.vash-signin');
if (existsSync(cfg)) await rename(cfg, aside);
return async () => {
if (existsSync(aside)) {
await rm(cfg, { force: true });
await rename(aside, cfg);
}
};
}

169
streamer/windows/viewer.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* A minimal remote screen for the one-time spectator sign-in, for someone who
* can only reach the PC over SSH. It shows the PC's screen a few times a
* second and forwards clicks and typing. It listens on 127.0.0.1 only and
* needs the random token printed by `vash-broadcast login`; reach it through
* an SSH tunnel. It exists only while the sign-in runs.
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Server } from 'bun';
import { authorized } from './control';
import type { Desktop } from './desktop';
/** Keys the page can send as virtual-key codes. */
const KEYS: Record<string, number> = {
Enter: 0x0d,
Tab: 0x09,
Backspace: 0x08,
Escape: 0x1b,
Delete: 0x2e,
ArrowLeft: 0x25,
ArrowUp: 0x26,
ArrowRight: 0x27,
ArrowDown: 0x28,
Home: 0x24,
End: 0x23
};
export interface ViewerOptions {
port: number;
token: string;
desktop: Desktop;
scratch: string;
title: string;
status: () => { message: string; done: boolean };
onFinish: () => void;
}
export function startViewer(options: ViewerOptions): Server<undefined> {
const { desktop } = options;
let screen = { width: 1920, height: 1080 };
let capturing: Promise<Uint8Array> | null = null;
const frame = join(options.scratch, 'viewer-frame.jpg');
const grab = async () => {
const session = await desktop.session();
screen = { width: session.screenWidth, height: session.screenHeight };
await desktop.capture({
x: 0,
y: 0,
width: screen.width,
height: screen.height,
path: frame,
format: 'jpeg',
maxWidth: 1280,
quality: 70
});
return new Uint8Array(await readFile(frame));
};
return Bun.serve({
hostname: '127.0.0.1',
port: options.port,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/')
return new Response(page(options.title), {
headers: { 'content-type': 'text/html; charset=utf-8' }
});
if (!authorized(request, options.token)) return new Response('Forbidden', { status: 403 });
try {
if (url.pathname === '/frame') {
capturing ??= grab().finally(() => (capturing = null));
return new Response(await capturing, {
headers: { 'content-type': 'image/jpeg', 'cache-control': 'no-store' }
});
}
if (url.pathname === '/status') return Response.json(options.status());
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
if (url.pathname === '/click' && request.method === 'POST') {
const x = Math.round(Math.max(0, Math.min(1, Number(body.x))) * (screen.width - 1));
const y = Math.round(Math.max(0, Math.min(1, Number(body.y))) * (screen.height - 1));
return Response.json({ ok: await desktop.click(x, y, body.right === true) });
}
if (url.pathname === '/type' && request.method === 'POST') {
const text = String(body.text ?? '').slice(0, 200);
return Response.json({ ok: (await desktop.type(text)) === text.length });
}
if (url.pathname === '/key' && request.method === 'POST') {
const vk = KEYS[String(body.key)];
if (!vk) return Response.json({ ok: false });
await desktop.key(vk);
return Response.json({ ok: true });
}
if (url.pathname === '/finish' && request.method === 'POST') {
options.onFinish();
return Response.json({ ok: true });
}
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
return new Response('Not found', { status: 404 });
}
});
}
function page(title: string) {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title.replace(/[<>&]/g, '')}</title>
<style>
:root { color-scheme: dark; --bg: #0c0c10; --panel: #17171d; --text: #ececf1; --muted: #9a9aa8; --accent: #ff66aa; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, sans-serif; }
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 12px 16px; background: var(--panel); }
header strong { font-size: 15px; }
#status { color: var(--muted); flex: 1; min-width: 200px; }
main { padding: 12px 16px; }
#screen { display: block; max-width: 100%; border-radius: 8px; outline: none; cursor: crosshair; background: #000; }
#screen:focus { box-shadow: 0 0 0 2px var(--accent); }
.row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; }
button, input { font: inherit; color: var(--text); background: #24242c; border: 1px solid #34343e; border-radius: 8px; padding: 6px 10px; }
button:hover { background: #2e2e38; }
button.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 600; }
input { min-width: 220px; }
p { color: var(--muted); max-width: 70ch; }
</style></head><body>
<header><strong>${title.replace(/[<>&]/g, '')}</strong><span id="status">Connecting…</span>
<button class="primary" id="finish">I have signed in: finish</button></header>
<main>
<p>Sign in to osu! with the spectator account and tick <b>Remember username</b> and <b>Remember password</b>. Click the screen to focus it; typing is then sent to the PC. Use the box below for passwords if your keyboard layout differs.</p>
<img id="screen" tabindex="0" alt="The broadcast PC's screen">
<div class="row"><input id="text" type="password" placeholder="Text to type on the PC" autocomplete="off">
<button id="send">Type it</button><button data-key="Enter">Enter</button><button data-key="Tab">Tab</button>
<button data-key="Backspace">Backspace</button><button data-key="Escape">Esc</button></div>
</main>
<script>
const token = decodeURIComponent(location.hash.slice(1));
const headers = { 'x-vash-token': token, 'content-type': 'application/json' };
const img = document.getElementById('screen');
const status = document.getElementById('status');
const post = (path, body) => fetch(path, { method: 'POST', headers, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => ({}));
let url = null;
async function refresh() {
try {
const r = await fetch('/frame', { headers });
if (r.ok) { const next = URL.createObjectURL(await r.blob()); img.src = next; if (url) URL.revokeObjectURL(url); url = next; }
else if (r.status === 403) { status.textContent = 'Wrong or missing token: open the exact link vash-broadcast login printed.'; return; }
} catch {}
setTimeout(refresh, 400);
}
async function poll() {
const s = await fetch('/status', { headers }).then(r => r.json()).catch(() => null);
if (s) { status.textContent = s.message; if (s.done) return; }
setTimeout(poll, 1500);
}
img.addEventListener('click', e => { const r = img.getBoundingClientRect(); img.focus(); post('/click', { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }); });
img.addEventListener('contextmenu', e => { e.preventDefault(); const r = img.getBoundingClientRect(); post('/click', { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height, right: true }); });
img.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (e.key.length === 1) post('/type', { text: e.key }); else post('/key', { key: e.key });
});
document.getElementById('send').onclick = () => { const t = document.getElementById('text'); post('/type', { text: t.value }); t.value = ''; };
document.querySelectorAll('[data-key]').forEach(b => b.onclick = () => post('/key', { key: b.dataset.key }));
document.getElementById('finish').onclick = () => post('/finish');
refresh(); poll();
</script></body></html>`;
}

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'bun';
import { layout, portsForSlot, readJson, saveConfig } from './config';
import { callWorker } from './control';
import { FakeDesktop } from './fakes';
import { runWorker, type WorkerState } from './worker';
const CHANNEL = '0b7f3c2a-1d2e-4f5a-8b9c-0d1e2f3a4b5c';
let home: string;
let vash: Server<undefined>;
const received: Record<string, unknown>[] = [];
const posts: Record<string, unknown>[] = [];
let live = false;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'vash-worker-'));
process.env.VASH_BROADCAST_HOME = home;
received.length = 0;
posts.length = 0;
live = false;
vash = Bun.serve({
port: 0,
async fetch(request, server) {
const url = new URL(request.url);
if (url.pathname === '/ws/agent')
return server.upgrade(request) ? undefined : new Response('no');
if (request.headers.get('authorization') !== 'Bearer vash_test')
return new Response('no', { status: 401 });
if (url.pathname === `/api/broadcast/${CHANNEL}/stream` && request.method === 'GET')
return Response.json({
live,
name: 'Main',
width: 1920,
height: 1080,
fps: 60,
bitrateKbps: 6000,
overlayUrl: `${url.origin}/broadcast/${CHANNEL}/overlay`,
record: false,
destinations: []
});
if (url.pathname === `/api/broadcast/${CHANNEL}/stream`) {
const body = (await request.json()) as Record<string, unknown>;
posts.push(body);
return Response.json(
body.event === 'start' ? { id: 'session-1', storagePrefix: null } : { ok: true }
);
}
return new Response('not found', { status: 404 });
},
websocket: {
message(ws, raw) {
const message = JSON.parse(String(raw));
received.push(message);
if (message.type === 'hello') {
ws.send(JSON.stringify({ type: 'welcome', channel: 'Main' }));
ws.send(
JSON.stringify({
type: 'follow',
scene: 'idle',
matchId: null,
lobbyId: null,
roomName: null,
teamSize: null,
title: null
})
);
}
}
}
});
await saveConfig({
version: 1,
name: 'test',
server: `http://127.0.0.1:${vash.port}`,
channel: CHANNEL,
token: 'vash_test',
access: null,
ports: portsForSlot(40 + Math.floor(Math.random() * 9)),
obsPassword: 'pw',
ingestKey: 'key',
osuSource: null,
tourneyHeight: 720,
encoder: 'amd',
autostart: false
});
});
afterEach(async () => {
vash.stop(true);
delete process.env.VASH_BROADCAST_HOME;
await rm(home, { recursive: true, force: true });
});
test('runs without its programs, reports why, follows Go live, and stops cleanly', async () => {
const paths = layout('test');
const desktop = new FakeDesktop();
const done = runWorker('test', desktop);
let status: WorkerState | null = null;
for (let waited = 0; waited < 80 && !status?.vash.connected; waited++) {
await Bun.sleep(100);
status = await callWorker<WorkerState>(paths.runtime, '/status');
}
expect(status?.vash).toMatchObject({ connected: true, channel: 'Main' });
// OBS is not installed here, so the worker says it is (re)starting it rather than claiming success.
expect(['starting', 'reconnecting']).toContain(status!.state);
expect(status!.tourney.state).not.toBe('spectating');
expect(received.some((message) => message.type === 'status' && message.instance === 'test')).toBe(
true
);
expect(existsSync(paths.lock)).toBe(true);
live = true;
for (let waited = 0; waited < 80 && !posts.length; waited++) await Bun.sleep(100);
expect(posts[0]).toEqual({ event: 'start' });
expect(await callWorker<{ ok: boolean }>(paths.runtime, '/stop', { method: 'POST' })).toEqual({
ok: true
});
expect(await done).toBe(0);
expect(posts.at(-1)).toMatchObject({ event: 'end', sessionId: 'session-1' });
expect(existsSync(paths.runtime)).toBe(false);
expect(existsSync(paths.lock)).toBe(false);
expect((await readJson<WorkerState>(paths.state))?.state).toBe('stopped');
expect(
received.some((message) => message.type === 'status' && message.state === 'stopping')
).toBe(true);
}, 30_000);

900
streamer/windows/worker.ts Normal file
View File

@@ -0,0 +1,900 @@
/**
* The unattended worker for one instance, running in the signed-in desktop:
*
* Vash (/ws/agent) ──follow──▶ osu!tourney: right team size, right room
* tosu ──live scores──▶ Vash; tosu ──team names──▶ room confirmed
* Vash (/stream) ──Go live──▶ relay listening ──▶ OBS streams into it
* OBS: overlay over the captured tourney window, AMD hardware encoding
* relay ──▶ every destination + HLS recording on the Vash site
*
* It restarts whatever stops (OBS, tosu, osu!tourney, the relay), reports what
* it is doing to Vash and to state.json, and stops cleanly when asked.
*/
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { VashLink, type Follow } from '../link';
import { Obs } from '../obs';
import { Broadcaster, RtmpIngest } from '../relay';
import { TosuFeed } from '../tosu';
import { VashApi, type StreamConfig } from '../vash-api';
import {
accessHeaders,
layout,
loadConfig,
readJson,
secret,
VERSION,
writeJson,
type InstanceConfig,
type Layout
} from './config';
import { authorized, callWorker, type Runtime } from './control';
import { WindowsDesktop, type Desktop, type SessionInfo } from './desktop';
import { acquireLock, lockOwner, releaseLock } from './lock';
import { fileLogger } from './log';
import { ObsScene } from './obs-control';
import { encoderFromLog, latestObsLog, OBS_ARGS, writeObsSettings } from './obs-files';
import { startAccessProxy } from './proxy';
import { Supervised, type Child } from './supervise';
import { normalModeFor, pickMainWindow, readSavedLogin, TourneyController } from './tourney';
import { startViewer } from './viewer';
export interface WorkerState {
instance: string;
version: string;
pid: number;
state: string;
detail: string | null;
updatedAt: string;
startedAt: string;
onAir: boolean;
live: boolean;
obsStreaming: boolean;
relayReceiving: boolean;
encoder: string | null;
match: {
id: string | null;
title: string | null;
lobbyId: number | null;
roomName: string | null;
} | null;
tourney: { state: string; detail: string | null; verified: boolean };
components: Record<string, unknown>;
destinations: { label: string; state: string; error: string | null }[];
vash: { connected: boolean; rejected: boolean; channel: string | null; lastError: string | null };
desktop: SessionInfo | null;
warnings: string[];
}
/** The same program, as a command line: the compiled exe, or bun + this script in development. */
export function selfCommand(...args: string[]) {
const compiled =
Bun.main.startsWith('B:/~BUN') || Bun.main.startsWith('/$bunfs') || Bun.main.includes('~BUN');
return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args];
}
const childOf = (process: import('bun').Subprocess): Child => ({
pid: process.pid,
exited: process.exited,
kill: () => process.kill()
});
/** Stops every process whose program lives in this instance's folder. */
export async function stopInstanceProcesses(
desktop: Desktop,
paths: Layout,
log: (...parts: unknown[]) => void
) {
const processes = await desktop.processes(paths.dir);
if (!processes.length) return 0;
log(`Stopping ${processes.length} leftover process(es) of this instance`);
return desktop.kill(processes.map((process) => process.pid));
}
/** Runs until asked to stop. Tests pass a fake desktop; Windows uses the PowerShell helper. */
export async function runWorker(name: string, desktop?: Desktop): Promise<number> {
const config = await loadConfig(name);
const paths = layout(name);
const log = fileLogger(paths.log);
log(
`vash-broadcast ${VERSION} worker starting for "${name}": ${config.server}, channel ${config.channel}`
);
const worker = new Worker(
config,
paths,
log,
desktop ?? new WindowsDesktop(paths.helperScript, log)
);
return worker.run();
}
class Worker {
private desktop: Desktop;
private api: VashApi;
private link: VashLink;
private tosu: TosuFeed;
private tourney: TourneyController;
private obs: Obs;
private scene: ObsScene;
private ingest: RtmpIngest;
private broadcaster: Broadcaster;
private obsProcess: Supervised;
private tosuProcess: Supervised;
private stopRequested = false;
private stopping = false;
private blockedBy: string | null = null;
private stream: StreamConfig | null = null;
private streamError: string | null = null;
private session: SessionInfo | null = null;
private follow: Follow | null = null;
private obsLaunchedWith = '';
private obsConnectedAt = 0;
private obsConnecting = false;
private obsStreaming = false;
private lastStartStream = 0;
private streamingSince = 0;
private encoder: string | null = null;
private warnings = new Set<string>();
private startedAt = new Date();
private controlToken = secret();
private headers: Record<string, string>;
private overlayUrl: string | null = null;
constructor(
private config: InstanceConfig,
private paths: Layout,
private log: (...parts: unknown[]) => void,
desktop: Desktop
) {
this.headers = accessHeaders(config);
this.desktop = desktop;
this.api = new VashApi(config.server, config.channel, config.token, this.headers);
this.tosu = new TosuFeed(
`ws://127.0.0.1:${config.ports.tosu}/websocket/v2`,
(frame) => this.link.sendScores(frame),
log
);
this.link = new VashLink({
server: config.server,
channel: config.channel,
token: config.token,
headers: this.headers,
log,
onFollow: (follow) => {
const changed =
follow.matchId !== this.follow?.matchId || follow.lobbyId !== this.follow?.lobbyId;
this.follow = follow;
if (changed)
log(
follow.title
? `Vash: now showing ${follow.title}${follow.lobbyId ? ` (mp/${follow.lobbyId})` : ''}`
: 'Vash: nothing to show'
);
this.tourney.follow(follow);
}
});
this.tourney = new TourneyController({
osuDir: paths.osu,
snapshots: paths.snapshots,
height: config.tourneyHeight,
windowTitle: `Vash osu!tourney (${config.name})`,
desktop: this.desktop,
tosu: this.tosu,
log
});
this.obs = new Obs(`ws://127.0.0.1:${config.ports.obs}`, config.obsPassword);
this.obs.onClose = (reason) => {
log(`OBS websocket closed: ${reason}`);
this.obsConnectedAt = 0;
this.obsStreaming = false;
this.scene.reset();
};
this.scene = new ObsScene(this.obs, log);
this.ingest = new RtmpIngest({
ffmpeg: paths.ffmpeg,
host: '127.0.0.1',
port: config.ports.ingest,
key: config.ingestKey,
log
});
this.broadcaster = new Broadcaster({
api: this.api,
ffmpeg: paths.ffmpeg,
hlsDir: paths.hls,
source: this.ingest,
log
});
this.obsProcess = new Supervised('OBS', () => this.launchObs(), { log });
this.tosuProcess = new Supervised(
'tosu',
() =>
childOf(
Bun.spawn([paths.tosuExe], {
cwd: paths.tosu,
stdout: 'ignore',
stderr: 'ignore',
windowsHide: true
})
),
{ log }
);
}
private async launchObs(): Promise<Child> {
const stream = this.stream;
const settings = {
websocketPort: this.config.ports.obs,
websocketPassword: this.config.obsPassword,
width: stream?.width ?? 1920,
height: stream?.height ?? 1080,
fps: stream?.fps ?? 60,
bitrateKbps: stream?.bitrateKbps ?? 6000,
encoder: this.config.encoder,
ingestUrl: this.ingest.url,
ingestKey: this.config.ingestKey
};
await writeObsSettings(this.paths.obsConfig, settings);
// Started before Vash answered: restart once the channel's real settings are known.
this.obsLaunchedWith = this.videoKey(stream) || 'defaults';
this.scene.reset();
const process = Bun.spawn([this.paths.obsExe, ...OBS_ARGS], {
cwd: dirname(this.paths.obsExe),
stdout: 'ignore',
stderr: 'ignore'
});
this.log(
`OBS started (${settings.width}×${settings.height} ${settings.fps} fps, ${settings.bitrateKbps} kbps, ${settings.encoder})`
);
return childOf(process);
}
/** OBS must restart (while off air) when these change. */
private videoKey(stream: StreamConfig | null) {
return stream ? `${stream.width}x${stream.height}@${stream.fps}/${stream.bitrateKbps}` : '';
}
async run(): Promise<number> {
const { paths, config, log } = this;
const server = Bun.serve({
hostname: '127.0.0.1',
port: config.ports.control,
fetch: async (request) => {
if (!authorized(request, this.controlToken))
return new Response('Forbidden', { status: 403 });
const url = new URL(request.url);
if (url.pathname === '/status') return Response.json(this.state());
if (url.pathname === '/stop' && request.method === 'POST') {
this.stopRequested = true;
return Response.json({ ok: true });
}
if (url.pathname === '/snapshot' && request.method === 'POST')
return Response.json(await this.snapshot());
return new Response('Not found', { status: 404 });
}
});
const runtime: Runtime = {
pid: process.pid,
watchdogPid: Number(process.env.VASH_WATCHDOG_PID) || null,
port: config.ports.control,
token: this.controlToken,
startedAt: this.startedAt.toISOString(),
version: VERSION
};
await writeJson(paths.runtime, runtime);
process.on('SIGTERM', () => (this.stopRequested = true));
process.on('SIGINT', () => (this.stopRequested = true));
// Leftovers of a crashed run would hold ports and windows; this instance's only.
await stopInstanceProcesses(this.desktop, paths, log).catch((err) => log('Cleanup:', err));
let proxy: ReturnType<typeof startAccessProxy> | null = null;
if (config.access) {
proxy = startAccessProxy({
port: config.ports.proxy,
upstream: config.server,
headers: this.headers,
log
});
log(`Overlay proxy for Cloudflare Access on 127.0.0.1:${config.ports.proxy}`);
}
this.link.start();
this.tosu.start();
// Sleep or a switched-off display would stop capture in the middle of a match.
await this.desktop.stayAwake(true).catch((err) => log('Could not keep the PC awake:', err));
let tick = 0;
while (!this.stopRequested) {
tick++;
try {
await this.step(tick);
} catch (err) {
log('Worker step failed:', err);
}
await this.writeState();
await Bun.sleep(1000);
}
await this.shutdown();
proxy?.stop(true);
server.stop(true);
return 0;
}
private async step(tick: number) {
const { paths, config, log } = this;
// One osu!tourney + tosu stack per PC.
if (this.blockedBy !== null || tick === 1) {
const lock = await acquireLock(paths.lock, config.name);
if (!lock.ok) {
if (this.blockedBy !== lock.owner.instance)
log(
`Waiting: instance "${lock.owner.instance}" runs osu!tourney on this PC (pid ${lock.owner.pid})`
);
this.blockedBy = lock.owner.instance;
// A holder whose worker has not answered for a minute is gone (its pid was reused).
const answering = await this.ownerAnswers(lock.owner.instance);
if (answering) this.silentSince = 0;
else if (!this.silentSince) this.silentSince = Date.now();
else if (Date.now() - this.silentSince > 60_000) {
log(
`Instance "${lock.owner.instance}" holds the tourney lock but does not answer; taking over`
);
await rm(paths.lock, { force: true });
this.silentSince = 0;
}
this.link.report(this.report());
await Bun.sleep(4000);
return;
}
if (this.blockedBy) log('The tourney stack is free; starting');
this.blockedBy = null;
}
if (tick % 2 === 1) this.session = await this.desktop.session().catch(() => null);
if (tick % 5 === 1) {
try {
const stream = await this.api.stream();
// OBS on this PC loads the overlay itself; behind Access, through the local proxy.
if (config.access)
stream.overlayUrl = stream.overlayUrl.replace(
/^https?:\/\/[^/]+/,
`http://127.0.0.1:${config.ports.proxy}`
);
this.stream = stream;
this.streamError = null;
} catch (err) {
if (this.streamError !== (err as Error).message) log('Vash stream settings:', err);
this.streamError = (err as Error).message;
}
}
// Programs: tosu and OBS always; osu!tourney once Vash said what to show (or after 15 s).
await this.tosuProcess.ensure();
if (
this.stream &&
this.obsLaunchedWith &&
this.obsLaunchedWith !== this.videoKey(this.stream) &&
!this.broadcaster.onAir
) {
log('Channel video settings changed; restarting OBS');
await this.stopObs();
}
await this.obsProcess.ensure();
await this.connectObs();
if (this.follow || Date.now() - this.startedAt.getTime() > 15_000)
if (tick % 2 === 0) await this.tourney.tick(this.session);
if (this.obs.connected && this.stream && tick % 3 === 0) {
const window = this.tourney.window;
await this.scene
.reconcile({
canvas: { width: this.stream.width, height: this.stream.height },
fps: this.stream.fps,
overlayUrl: this.stream.overlayUrl,
capture: window
? {
title: `Vash osu!tourney (${config.name})`,
className: window.className,
exe: 'osu!.exe'
}
: null,
showGameplay: this.tourney.state === 'spectating',
ingest: { server: this.ingest.url, key: config.ingestKey }
})
.catch((err) => log('OBS scene:', err));
}
// On and off air, following the channel's Go live switch.
if (this.stream && tick % 5 === 1)
await this.broadcaster.apply(this.stream).catch((err) => log('Broadcast:', err));
await this.broadcaster.tick();
await this.syncObsStream();
this.link.report(this.report());
}
private silentSince = 0;
private async ownerAnswers(instance: string) {
try {
return !!(await callWorker(layout(instance).runtime, '/status'));
} catch {
return false;
}
}
private async connectObs() {
if (!this.obsProcess.running || this.obs.connected || this.obsConnecting) return;
this.obsConnecting = true;
try {
await this.obs.connect();
this.obsConnectedAt = Date.now();
this.log('OBS websocket connected');
const encoder = await this.scene.profileEncoder().catch(() => null);
if (this.config.encoder === 'amd' && encoder && encoder !== 'amd')
this.warn(
`OBS switched the encoder to ${encoder}: the AMD hardware encoder is not available (see OBS's log)`
);
} catch (err) {
const upFor = Date.now() - this.obsProcess.startedAt;
if (upFor > 90_000) {
this.log(
`OBS is running but its websocket does not answer (${(err as Error).message}); restarting OBS`
);
await this.stopObs();
}
} finally {
this.obsConnecting = false;
}
}
/** OBS streams into the relay while on air, and not otherwise. */
private async syncObsStream() {
if (!this.obs.connected) {
this.obsStreaming = false;
return;
}
const status = await this.scene.streaming().catch(() => null);
if (!status) return;
const was = this.obsStreaming;
this.obsStreaming = status.outputActive;
if (this.obsStreaming && !was) {
this.streamingSince = Date.now();
this.encoder = null;
}
const wanted =
this.broadcaster.onAir &&
this.ingest.running &&
Date.now() - this.ingest.listeningSince > 1500;
if (wanted && !status.outputActive && Date.now() - this.lastStartStream > 10_000) {
this.lastStartStream = Date.now();
this.log('Starting the OBS stream into the relay');
await this.scene.startStream().catch((err) => this.log('OBS StartStream:', err));
} else if (!this.broadcaster.onAir && status.outputActive) {
this.log('Stopping the OBS stream');
await this.scene.stopStream().catch((err) => this.log('OBS StopStream:', err));
}
// Which encoder actually runs, from OBS's own log, a few seconds into the stream.
if (this.obsStreaming && !this.encoder && Date.now() - this.streamingSince > 5000) {
const found = encoderFromLog(await latestObsLog(this.paths.obsConfig));
this.encoder =
found === 'amd'
? 'AMD AMF (hardware)'
: found === 'x264'
? 'x264 (software)'
: (found ?? 'unknown');
this.log(`OBS encoder in use: ${this.encoder}`);
if (this.config.encoder === 'amd' && found !== 'amd')
this.warn(`Streaming with ${this.encoder}, not the AMD hardware encoder`);
}
}
private warn(message: string) {
if (this.warnings.has(message)) return;
this.warnings.add(message);
this.log(`Warning: ${message}`);
}
private async stopObs() {
this.obs.close();
this.obsConnectedAt = 0;
await this.obsProcess.stop(async (child) => {
await this.desktop.close(child.pid);
});
}
/** The one state shown on the site and in `vash-broadcast status`. */
private overall(): { state: string; detail: string | null } {
if (this.stopping) return { state: 'stopping', detail: null };
if (this.blockedBy)
return {
state: 'blocked',
detail: `Instance "${this.blockedBy}" is running osu!tourney on this PC`
};
if (this.link.rejected)
return {
state: 'error',
detail: 'Vash refused the agent token; run setup again with a new token'
};
if (this.streamError && !this.stream)
return { state: 'error', detail: `Cannot reach Vash: ${this.streamError}` };
const tourney = this.tourney.state;
if (tourney === 'login-required')
return { state: 'login-required', detail: this.tourney.detail };
if (!this.obs.connected)
return {
state: this.obsProcess.restarts ? 'reconnecting' : 'starting',
detail: this.obsProcess.lastExit ? `OBS ${this.obsProcess.lastExit}` : 'starting OBS'
};
if (this.broadcaster.onAir && !this.obsStreaming && Date.now() - this.lastStartStream > 20_000)
return { state: 'reconnecting', detail: 'On air, but OBS is not streaming into the relay' };
if (tourney === 'stopped')
return { state: 'starting', detail: 'waiting for Vash to say what to show' };
const map: Record<string, string> = {
starting: 'starting',
'desktop-locked': 'desktop-locked',
idle: 'idle',
'waiting-for-lobby': 'waiting-for-lobby',
selecting: 'selecting',
spectating: 'spectating',
error: 'error'
};
return { state: map[tourney] ?? 'error', detail: this.tourney.detail };
}
private report() {
const { state, detail } = this.overall();
return {
state,
detail,
onAir: this.broadcaster.onAir && this.obsStreaming,
encoder: this.encoder,
instance: this.config.name,
version: VERSION
};
}
state(): WorkerState {
const { state, detail } = this.overall();
const follow = this.follow;
return {
instance: this.config.name,
version: VERSION,
pid: process.pid,
state,
detail,
updatedAt: new Date().toISOString(),
startedAt: this.startedAt.toISOString(),
onAir: this.broadcaster.onAir,
live: !!this.stream?.live,
obsStreaming: this.obsStreaming,
relayReceiving: this.ingest.receiving,
encoder: this.encoder,
match: follow
? {
id: follow.matchId,
title: follow.title,
lobbyId: follow.lobbyId,
roomName: follow.roomName
}
: null,
tourney: {
state: this.tourney.state,
detail: this.tourney.detail,
verified: this.tourney.verified
},
components: {
obs: { ...this.obsProcess.status(), websocket: this.obs.connected },
tosu: {
...this.tosuProcess.status(),
connected: this.tosu.connected,
players: this.tosu.fresh()?.clients.length ?? 0,
frameAt: this.tosu.latestAt ? new Date(this.tosu.latestAt).toISOString() : null
},
scores: {
players: this.link.scores.players,
sentAt: this.link.scores.sentAt ? new Date(this.link.scores.sentAt).toISOString() : null,
acceptedAt: this.link.scores.acceptedAt
? new Date(this.link.scores.acceptedAt).toISOString()
: null
},
relay: {
listening: this.ingest.running,
receiving: this.ingest.receiving,
recording: this.broadcaster.recording
}
},
destinations: this.broadcaster
.destinations()
.map(({ label, state, error }) => ({ label, state, error })),
vash: {
connected: this.link.connected,
rejected: this.link.rejected,
channel: this.link.channelName,
lastError: this.streamError
},
desktop: this.session,
warnings: [...this.warnings]
};
}
private lastStateWrite = 0;
private async writeState(force = false) {
if (!force && Date.now() - this.lastStateWrite < 2000) return;
this.lastStateWrite = Date.now();
await writeJson(this.paths.state, this.state()).catch(() => {});
}
/** Saves what OBS outputs and what the tourney window shows, for checking from SSH. */
private async snapshot() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
await mkdir(this.paths.snapshots, { recursive: true });
const files: string[] = [];
const errors: string[] = [];
if (this.obs.connected) {
try {
const file = join(this.paths.snapshots, `${stamp}-obs-output.png`);
await writeFile(file, await this.scene.screenshot());
files.push(file);
} catch (err) {
errors.push(`OBS: ${(err as Error).message}`);
}
} else errors.push('OBS is not connected');
const window = this.tourney.window;
if (window) {
try {
const file = join(this.paths.snapshots, `${stamp}-tourney-window.png`);
await this.desktop.capture({
x: window.clientX,
y: window.clientY,
width: window.clientWidth,
height: window.clientHeight,
path: file
});
files.push(file);
} catch (err) {
errors.push(`Tourney window: ${(err as Error).message}`);
}
} else errors.push('No osu!tourney window');
const panel = join(this.paths.snapshots, 'tourney-panel.png');
if (existsSync(panel)) files.push(panel, join(this.paths.snapshots, 'tourney-panel.json'));
return { files, errors };
}
private async shutdown() {
const { log, paths } = this;
this.stopping = true;
log('Stopping');
this.link.report(this.report());
await this.writeState(true);
if (this.obs.connected && this.obsStreaming) await this.scene.stopStream().catch(() => {});
await this.broadcaster.stop().catch((err) => log('Going off air:', err));
await this.tourney.stop().catch((err) => log('Stopping osu!tourney:', err));
await this.stopObs().catch(() => {});
await this.tosuProcess.stop();
this.tosu.stop();
this.link.stop();
await stopInstanceProcesses(this.desktop, paths, log).catch(() => {});
this.desktop.stop();
await releaseLock(paths.lock);
await writeJson(paths.state, { ...this.state(), state: 'stopped', detail: null }).catch(
() => {}
);
await rm(paths.runtime, { force: true });
log('Stopped');
}
}
// ── Watchdog ────────────────────────────────────────────────────────────
/**
* What the scheduled task runs. It restarts the worker if it crashes (with
* backoff) and exits when the worker stops on purpose. The request file says
* whether this start is the worker, the one-time sign-in, or a diagnosis.
*/
export async function runTask(name: string): Promise<number> {
const paths = layout(name);
const log = fileLogger(paths.log);
const request = await readJson<{ mode: string; at: number }>(paths.request);
await rm(paths.request, { force: true });
const mode = request && Date.now() - request.at < 5 * 60_000 ? request.mode : 'run';
if (mode === 'login') return runSignIn(name);
if (mode === 'doctor') return runDesktopDoctor(name);
const owner = await lockOwner(paths.lock);
const runtime = await readJson<Runtime>(paths.runtime);
if (runtime && owner?.pid === runtime.pid) {
log('The worker is already running; not starting another');
return 0;
}
let failures = 0;
for (;;) {
const started = Date.now();
const child = Bun.spawn(selfCommand('worker', '--instance', name), {
stdout: 'ignore',
stderr: 'pipe',
env: { ...process.env, VASH_WATCHDOG_PID: String(process.pid) },
windowsHide: true
});
let stderr = '';
void (async () => {
for await (const chunk of child.stderr)
stderr = (stderr + new TextDecoder().decode(chunk)).slice(-2000);
})();
const code = await child.exited;
if (code === 0) return 0;
if (Date.now() - started > 10 * 60_000) failures = 0;
const wait = Math.min(60_000, 2000 * 2 ** failures++);
log(
`Worker exited with ${code}${stderr ? `: ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''}; restarting in ${Math.round(wait / 1000)}s`
);
if (existsSync(paths.request)) return 0;
await Bun.sleep(wait);
}
}
// ── One-time sign-in ────────────────────────────────────────────────────
export interface SignInState {
state: 'starting' | 'waiting' | 'closing' | 'done' | 'failed';
message: string;
port: number;
token: string;
username: string | null;
updatedAt: string;
}
async function runSignIn(name: string): Promise<number> {
const config = await loadConfig(name);
const paths = layout(name);
const log = fileLogger(paths.log);
const desktop = new WindowsDesktop(paths.helperScript, log);
const token = secret();
let finish = false;
let state: SignInState = {
state: 'starting',
message: 'Starting osu!…',
port: config.ports.viewer,
token,
username: null,
updatedAt: new Date().toISOString()
};
const set = async (next: Partial<SignInState>) => {
state = { ...state, ...next, updatedAt: new Date().toISOString() };
await writeJson(paths.login, state);
};
log('One-time sign-in: starting osu! normally');
await stopInstanceProcesses(desktop, paths, log).catch(() => {});
const restore = await normalModeFor(paths.osu);
const viewer = startViewer({
port: config.ports.viewer,
token,
desktop,
scratch: paths.snapshots,
title: `Vash broadcast sign-in (${name})`,
status: () => ({
message: state.message,
done: state.state === 'done' || state.state === 'failed'
}),
onFinish: () => (finish = true)
});
await mkdir(paths.snapshots, { recursive: true });
try {
Bun.spawn([paths.osuExe], { cwd: paths.osu, stdout: 'ignore', stderr: 'ignore' });
await set({
state: 'waiting',
message:
'osu! is starting (a fresh installation downloads its files first). Sign in with the spectator account, tick Remember username and Remember password, then press Finish.'
});
const deadline = Date.now() + 45 * 60_000;
while (!finish && Date.now() < deadline) {
await Bun.sleep(1000);
const window = pickMainWindow(await desktop.windows(paths.osu).catch(() => []));
if (window && state.message.startsWith('osu! is starting'))
await desktop.focus(window.hwnd).catch(() => false);
}
await set({ state: 'closing', message: 'Closing osu! so it saves the login…' });
for (const process of await desktop.processes(paths.osu))
await desktop.close(process.pid).catch(() => 0);
for (
let waited = 0;
waited < 20_000 && (await desktop.processes(paths.osu)).length;
waited += 1000
)
await Bun.sleep(1000);
await stopInstanceProcesses(desktop, paths, log).catch(() => {});
await restore();
const login = await readSavedLogin(paths.osu);
if (login.saved) {
await set({
state: 'done',
username: login.username,
message: `Signed in as ${login.username}; the login is saved.`
});
log(`One-time sign-in done: ${login.username}`);
} else {
await set({
state: 'failed',
message: finish
? 'osu! did not save a login. Run vash-broadcast login again and tick Remember username and Remember password.'
: 'The sign-in timed out after 45 minutes.'
});
}
} catch (err) {
await restore().catch(() => {});
await set({ state: 'failed', message: (err as Error).message });
} finally {
await Bun.sleep(3000);
viewer.stop(true);
desktop.stop();
}
return 0;
}
// ── Diagnosis inside the desktop ─────────────────────────────────────────
async function runDesktopDoctor(name: string): Promise<number> {
const paths = layout(name);
const log = fileLogger(paths.log);
const desktop = new WindowsDesktop(paths.helperScript, log);
const checks: { name: string; ok: boolean; detail: string }[] = [];
const check = async (label: string, run: () => Promise<string>) => {
try {
checks.push({ name: label, ok: true, detail: await run() });
} catch (err) {
checks.push({ name: label, ok: false, detail: (err as Error).message });
}
};
await check('Desktop session', async () => {
const session = await desktop.session();
if (session.inputDesktop !== 'Default')
throw new Error(
`The desktop is locked or on a secure screen (${session.inputDesktop ?? 'no input desktop'}); rooms cannot be clicked`
);
if (session.sessionId !== session.consoleSessionId)
throw new Error(
`This session (${session.sessionId}) is not the one on the PC's screen (${session.consoleSessionId}); a disconnected Remote Desktop session has no display`
);
return `unlocked, ${session.screenWidth}×${session.screenHeight}, idle ${Math.round(session.idleMs / 1000)}s`;
});
await check('Screen capture', async () => {
await mkdir(paths.snapshots, { recursive: true });
const file = join(paths.snapshots, 'doctor-screen.jpg');
const size = await desktop.capture({
x: 0,
y: 0,
width: 640,
height: 360,
path: file,
format: 'jpeg'
});
return `captured ${size.width}×${size.height} to ${file}`;
});
await check('Windows OCR', async () => {
const ready = await desktop.ocrReady();
return `language ${ready.language}, images up to ${ready.maxDimension} px`;
});
await check('osu!tourney window', async () => {
const windows = await desktop.windows(paths.osu);
return windows.length
? windows
.map((window) => `"${window.title}" ${window.clientWidth}×${window.clientHeight}`)
.join('; ')
: 'osu! is not running (normal while the worker is stopped)';
});
await check('AMD encoder in OBS log', async () => {
const log = await latestObsLog(paths.obsConfig);
if (!log) return 'no OBS log yet; start the worker once';
const lines = log
.split(/\r?\n/)
.filter((line) => /amf|AMD|encoder/i.test(line))
.slice(0, 12);
return lines.join(' | ') || 'no encoder lines in the latest OBS log';
});
desktop.stop();
await writeJson(paths.doctor, { at: new Date().toISOString(), checks });
return 0;
}