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:
122
streamer/ALPHA_TEST.md
Normal file
122
streamer/ALPHA_TEST.md
Normal 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.
|
||||
@@ -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> \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
283
streamer/link.test.ts
Normal 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
196
streamer/link.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
|
||||
139
streamer/obs.ts
139
streamer/obs.ts
@@ -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
550
streamer/relay.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["*.ts", "tv/*.ts"]
|
||||
"include": ["*.ts", "tv/*.ts", "windows/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
82
streamer/vash-api.ts
Normal 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
4
streamer/windows/assets.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.ps1' {
|
||||
const text: string;
|
||||
export default text;
|
||||
}
|
||||
716
streamer/windows/cli.ts
Normal file
716
streamer/windows/cli.ts
Normal 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
239
streamer/windows/config.ts
Normal 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
|
||||
}
|
||||
: {};
|
||||
}
|
||||
44
streamer/windows/control.ts
Normal file
44
streamer/windows/control.ts
Normal 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;
|
||||
}
|
||||
503
streamer/windows/desktop.ps1
Normal file
503
streamer/windows/desktop.ps1
Normal 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
278
streamer/windows/desktop.ts
Normal 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
122
streamer/windows/fakes.ts
Normal 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
381
streamer/windows/install.ts
Normal 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
68
streamer/windows/lock.ts
Normal 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
40
streamer/windows/log.ts
Normal 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 */
|
||||
}
|
||||
};
|
||||
}
|
||||
313
streamer/windows/obs-control.ts
Normal file
313
streamer/windows/obs-control.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
212
streamer/windows/obs-files.ts
Normal file
212
streamer/windows/obs-files.ts
Normal 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(() => '') : '';
|
||||
}
|
||||
382
streamer/windows/obs.test.ts
Normal file
382
streamer/windows/obs.test.ts
Normal 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
89
streamer/windows/proxy.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
231
streamer/windows/runtime.test.ts
Normal file
231
streamer/windows/runtime.test.ts
Normal 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')
|
||||
);
|
||||
});
|
||||
});
|
||||
123
streamer/windows/supervise.ts
Normal file
123
streamer/windows/supervise.ts
Normal 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
132
streamer/windows/tasks.ts
Normal 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);
|
||||
}
|
||||
250
streamer/windows/tourney.test.ts
Normal file
250
streamer/windows/tourney.test.ts
Normal 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
437
streamer/windows/tourney.ts
Normal 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
169
streamer/windows/viewer.ts
Normal 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>`;
|
||||
}
|
||||
131
streamer/windows/worker.test.ts
Normal file
131
streamer/windows/worker.test.ts
Normal 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
900
streamer/windows/worker.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user