# sixeldoor

A BBS door that streams YouTube video into a retro CRT-TV drawn with ANSI art.
Video frames render as JPEG XL or sixel graphics inside the TV's screen. On
SyncTERM 1.10+, real synced audio plays through the terminal's own mixer — no
browser or second device needed. Everyone else (older SyncTERM, or any other
sixel-capable client) automatically falls back to a synced browser-audio URL
instead of silence. Works with any BBS that writes `door32.sys`. Requires
**SyncTERM** (tested); NetRunner/MagiTerm/IGTerm will draw the ANSI UI but
the video area will show sixel escape-sequence garbage, since they don't
decode sixel.

This is a **prebuilt, ready-to-deploy package** — the binary is statically
linked, so there's nothing to compile on the BBS server: no Go toolchain, no
shared libraries, no source tree required. This guide only covers deploying
and configuring the binary you already have.

## What's in this package

- the platform's binary (`sixeldoor` on Linux, `sixeldoor.exe` on Windows)
- its matching `run.sh` (Linux) or `run.bat` (Windows) wrapper — sets the
  `$PATH`/environment the door needs before launching the binary; **edit
  this file**, not scfg/MIS/the binary itself, to configure hostname and
  ports (see Deployment sections below)
- `FILE_ID.DIZ`, labeled for this specific platform/architecture
- this `README.md`
- **Linux package only:**
  - `install-bbs-deps.sh` — installs yt-dlp/ffmpeg/Deno on the server (see
    Runtime dependencies, below)
  - `update-yt-dlp-nightly.sh` — standalone maintenance script (see
    "Fixing intermittent HTTP 403 errors")
  - `ml-syncterm-install.sh` — builds a full-featured SyncTERM *client* for
    players on Linux/BSD (see "Building a full-featured SyncTERM client")
- **Windows packages only:**
  - `sixeldoor-setup.exe` — run this first. One-time installer that
    fetches and configures everything else in this list (see "Runtime
    dependencies" below for what it does and doesn't cover).
  - `update-yt-dlp-nightly.bat` + `update-yt-dlp-nightly.ps1` (ship
    together, same purpose as the Linux script above)
  - **64-bit Windows packages only** (`sixeldoor-win64`,
    `sixeldoor-win7-x64`): `curl.exe` + `curl-ca-bundle.crt`, a
    self-contained curl build with its own bundled TLS library. Used
    automatically by `update-yt-dlp-nightly.ps1` in place of PowerShell's
    own HTTPS client, since very old/unpatched Windows installs can lack
    TLS 1.2 support at the OS level entirely (confirmed live on a real
    Windows 7 SP1 box) — no amount of script-side configuration can add
    that back, but a TLS-independent tool can route around it. No 32-bit
    build of this exists from its official source anymore, so the 32-bit
    Windows packages don't include one; `update-yt-dlp-nightly.ps1` falls
    back to PowerShell's own HTTPS client automatically when `curl.exe`
    isn't present, so it's still fully functional on any 32-bit Windows
    install with working TLS 1.2 (i.e., any reasonably updated one).

## Runtime dependencies

**Windows: run `sixeldoor-setup.exe` first.** It downloads and configures
yt-dlp, a JS runtime, and (64-bit only) ffmpeg and the PO token provider
automatically — everything below this point except SyncTERM itself. Built
with the same Go toolchain as sixeldoor, so it has its own self-contained
TLS stack and doesn't depend on whatever PowerShell/.NET/certificate-store
state happens to already be on the target box (a real, very old Windows 7
install hit every one of those gaps during this door's own development —
see "Windows 7: official yt-dlp will not run at all" further down for
what that tool routes around automatically). Just run it once from the
folder you extracted the
package into, and read its summary at the end — some 32-bit-specific
pieces (ffmpeg, the PO token provider) have no verified official 32-bit
Windows build to fetch and are intentionally skipped with instructions
instead of guessed at. The rest of this section documents what it does,
for troubleshooting or if you'd rather do it by hand.

Must be on the BBS server's `$PATH` (or in the hardcoded fallback list the
door itself checks — `~/.local/bin`, then `/usr/local/bin`, then `/usr/bin`
on Linux; next to the binary on Windows):

- **yt-dlp** — do NOT use distro packages (`apt install yt-dlp`, etc.), they
  rot fast. Install via pip and keep it current:

      pip install --user --upgrade yt-dlp
      # PEP 668 "externally managed" error on newer Debian/Ubuntu? add:
      pip install --user --upgrade --break-system-packages yt-dlp

  Even fully up to date, expect occasional `HTTP Error 403: Forbidden` from
  YouTube's Proof-of-Origin token enforcement — see "Fixing intermittent
  HTTP 403 errors" below; the door's built-in retry logic absorbs most of
  this either way, and that section also covers what to do if 403s become
  *constant* instead of occasional (a real, fairly common yt-dlp/YouTube
  break, not something this door can fix on its own).

- **A JavaScript runtime** on the server's `$PATH` — Deno by default. Recent
  yt-dlp needs to execute a bit of JS to resolve some YouTube formats'
  signatures; without one, yt-dlp still runs but silently falls back to
  whatever formats it can resolve unaided, and downloads intermittently
  fail with `HTTP Error 403: Forbidden` (the format yt-dlp picked without
  JS support turned out to have an invalid/expired URL). Install per
  yt-dlp's own current instructions:
  https://github.com/yt-dlp/yt-dlp/wiki/EJS — as of writing:

      curl -fsSL https://deno.land/install.sh | sh

  Make sure wherever it lands (`~/.deno/bin` by default) is actually on the
  `$PATH` your `run.sh`/`run.bat` wrapper exports — installers typically
  only add it to interactive shell rc files, which the door's own launch
  doesn't go through. `run.sh`/`run.bat` already include `~/.deno/bin` in
  their `PATH` line; double-check it matches where your install actually
  landed.

- **ffmpeg** — any recent version, built with at least `libmp3lame` (needed
  by the browser-audio fallback; `libopus`/`libvorbis` are used for native
  SyncTERM audio when available and fall back to uncompressed WAV if not).
  Used to decode+rescale video and to transcode audio. **Optional but
  recommended:** a `libjxl`-enabled build — see "Getting a libjxl-enabled
  ffmpeg" under Video rendering, below. Most distro packages don't enable
  this; not fatal if missing, the door checks at startup and falls back to
  sixel automatically.

**Linux, automated:** `install-bbs-deps.sh` installs all three (yt-dlp,
Deno, ffmpeg) in one pass, plus optional flags for a libjxl-enabled ffmpeg
and a YouTube anti-bot mitigation:

    sudo ./install-bbs-deps.sh
    sudo ./install-bbs-deps.sh --help   # see all optional flags

**Windows:** no equivalent automated script — install yt-dlp and Deno per
their own Windows instructions, and download a static `ffmpeg.exe` (e.g.
from [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds/releases) —
see "Getting a libjxl-enabled ffmpeg" below, which covers exactly this).
Drop `yt-dlp.exe`, `deno.exe`, and `ffmpeg.exe` next to `sixeldoor.exe`, or
anywhere referenced by `run.bat`'s `PATH` line.

## Building a full-featured SyncTERM client

Everything above is about the BBS *server* side. Players also need a
SyncTERM build with sixel, JPEG XL, and native audio all actually compiled
in — distro-packaged SyncTERM builds are hit or miss on the last two.

**Linux/BSD players:** `ml-syncterm-install.sh` (included in the Linux
package, originally written by **MeaTLoTioN of thE qUAntUm wOrmhOlE BBS** —
see Credits) builds SyncTERM from source on the player's own machine
(Debian/Ubuntu, Fedora, or Arch) with the right `-dev` packages installed
first so `pkg-config` picks up both `libjxl` and `libsndfile`
automatically, and it double-checks the build log confirms both actually
got detected:

    ./ml-syncterm-install.sh

Run this on whatever machine will be *connecting to* the BBS, not the BBS
server itself. (This builds the SyncTERM *client* project from its own
public source — unrelated to sixeldoor's own binary, which needs no
building at all.)

**Windows players:** use the official prebuilt Windows SyncTERM release —
see https://syncterm.net/.

## Deployment on Synchronet

1. Copy the binary and `run.sh`/`run.bat` to the server. Convention (Linux):

       mkdir -p /sbbs/xtrn/sixelyt
       cp sixeldoor run.sh /sbbs/xtrn/sixelyt/
       chmod +x /sbbs/xtrn/sixelyt/sixeldoor /sbbs/xtrn/sixelyt/run.sh

   Edit `run.sh` (or `run.bat` on Windows) first — set `SIXELDOOR_HOSTNAME`
   to your BBS's actual public hostname (see Audio, below, for why).

2. In `scfg` (or SBBSCTRL → BBS → Configure):

       External Programs → Online Programs (Doors) → (Add)

   Configure:

   | Field | Value |
   |---|---|
   | Name | `YouTube (Sixel)` |
   | Internal Code | `SIXELYT` |
   | Start-up Directory | `/sbbs/xtrn/sixelyt` |
   | Command Line | `run.sh %f` (or `run.bat %f` on Windows) |
   | Native Executable/Script | `Yes` |
   | I/O Method | **Linux: `Standard I/O`**, not `Socket`. This build always talks over stdin/stdout on Linux, so the BBS needs to redirect the connection onto stdio itself before exec'ing the door. Older scfg versions may instead show a field called "Intercept I/O" — set that to `Yes`, same meaning. If neither is offered and only `Socket` is, this build will not work correctly on Linux. **Windows: `Socket`**, not `Standard I/O` — confirmed live getting this backwards on a real Windows box: with `Standard I/O` set, the door logs `falling back to stdio ... output will appear locally on the BBS console, NOT to the user` and runs under GUEST defaults, since Synchronet never hands it a socket at all. The Windows packages talk over a real Winsock socket handle, not stdio — that's the whole reason a separate Windows build exists. |
   | BBS Drop File Type | `DOOR32.SYS` |
   | Place Drop File in | `Node Directory` — important for multi-node BBSes: a shared/global dropfile location causes one connecting user's session to overwrite another's mid-use |
   | Multiple Concurrent Users | `Yes` |
   | Modify User Data | `No` — this door never writes back to the dropfile |
   | Access Requirements | your policy — e.g. `LEVEL 20`, or leave blank for everyone |
   | Execution Cost | `0` |

   `%f` expands to the full path of `door32.sys` in the node directory —
   this is how the door finds and reads it. Field names and exact wording
   can drift slightly between Synchronet versions; if a field described
   above isn't present under that exact name, look for the nearest
   equivalent (e.g. some versions group "Native Executable" and I/O
   behavior into a single dropdown).

3. Restart the relevant node(s). The door appears under the
   external-programs menu.

### Synchronet terminal-capability gotcha

Synchronet passes the user's terminal emulation in `door32.sys` line 10
(0=ASCII, 1=ANSI, 2=AVATAR, 3=RIP, 4=MaxGfx). None of these distinguish
sixel-capable from non-capable clients. The door sends its own Device
Attributes query (`ESC[c`) at startup and parses the response for sixel
support before drawing anything heavy; if the terminal doesn't answer with
sixel support, it shows a "this door requires SyncTERM" message instead of
garbage.

## Deployment on Mystic BBS

Mystic natively supports `door32.sys` (it created the format) via a
dedicated menu-command type, **`D3` — "Door with DOOR32.SYS"** — which
generates the dropfile for you in the connecting node's own temp
directory (e.g. `temp1/door32.sys` for node 1) each time the command
runs, avoiding cross-node collisions automatically.

1. Copy the binary and `run.sh` to the server:

       mkdir -p /mystic/doors/sixelyt
       cp sixeldoor run.sh /mystic/doors/sixelyt/
       chmod +x /mystic/doors/sixelyt/sixeldoor /mystic/doors/sixelyt/run.sh

   Edit `run.sh` first — set `SIXELDOOR_HOSTNAME` (see Audio, below).

2. Open Mystic's configuration utility (commonly run as `mystic -cfg`, or
   via the MIS web config if you use that instead) and go to the **Menus**
   section. Select the theme whose menu you want to add this to, then the
   target menu (e.g. `main`), and add a new command/option to it (exact
   keystrokes vary a little by Mystic version — look for an "insert new
   command" action in that screen, commonly bound to `Insert` or `/` then
   `I`).

3. Configure the new option:

   | Field | Value |
   |---|---|
   | Hot Key | whatever key you want (e.g. `Y`) |
   | Long/Short Description | `YouTube (Sixel)` |
   | ACS / Access | your policy, or blank for everyone |
   | Command | `D3` (Door with DOOR32.SYS) |
   | Data | `/mystic/doors/sixelyt/run.sh %Pdoor32.sys` |

   `%P` expands to the node's temp directory *with a trailing slash*, and
   Mystic always names the file `door32.sys` (lowercase) — so
   `%Pdoor32.sys` gives the exact dropfile path as a single command-line
   argument, matching how `%f` works on Synchronet.

4. Save, exit the config utility, and test by logging in and selecting the
   new menu option.

Note: some very old Mystic-adjacent documentation references a `*DOOR32*`
token instead of `%Pdoor32.sys` — that token does not exist in Mystic's
current command-line token set (`%U` `%R` `%A` `%B` `%T` `%P` `%N` `%H`,
per Mystic's own wiki); use `%Pdoor32.sys` as shown above.

## Deployment on other door32.sys-based BBS software

`door32.sys` has become a semi-standard dropfile format beyond the two
BBS packages above, so the same general recipe applies to any door-capable
BBS software that supports it:

1. Configure the door's command line to run `run.sh`/`run.bat`, passing the
   dropfile's path as an argument if your BBS software supports a
   substitution token for that (commonly `%f`, sometimes something else —
   check your BBS's own door-configuration documentation for its exact
   token).
2. If your BBS software has no such token, or doesn't pass the dropfile
   path as an argument at all, the door still tries to auto-discover it:
   it checks a `DOOR32` environment variable, then `door32.sys`/`DOOR32.SYS`
   in the current working directory, then an `%MTEMP%`-style node temp
   directory if that environment variable is set, then beside its own
   binary. Setting the door's *startup directory* to wherever your BBS
   actually writes the dropfile is usually enough even without an explicit
   argument.
3. If your BBS supports running multiple simultaneous door sessions (one
   per connecting user), make sure each gets its own dropfile — a global,
   shared dropfile location will cause one user's session to clobber
   another's identity mid-connection on any multi-node setup.
4. If nothing else, `/tmp/sixeldoor.log` (Linux) always records exactly
   which dropfile path it tried and why it failed, if it did — start there
   when troubleshooting a new BBS package's integration.

### Command-line overrides (no dropfile file needed)

Some BBS software doesn't write a dropfile at all and instead passes node
number / socket handle directly as launch arguments (or a sysop just wants
to test without setting up a real BBS session first). The door accepts a
small set of flags for this, deliberately using the same letters as
[RMDoor](https://github.com/M-Hufton/RMDoor)'s own `-D`/`-N`/`-S`/`-L`
convention (a widely-used reference point among door32.sys-based doors) so
they're familiar if you've configured other doors before:

| Flag | Long form | Meaning |
|---|---|---|
| `-D<path>` | `--dropfile=<path>` | Explicit dropfile path — same as passing a bare path as the only argument (e.g. Synchronet's `%f`); this flag form is just for when you need to combine it with the others below on one command line |
| `-N<n>` | `--node=<n>` | Override the node number, regardless of what a loaded dropfile says |
| `-S<n>` | `--socket=<n>` | **Windows builds only.** Override the socket/comm handle directly — use this if your BBS passes a raw socket number on the command line instead of writing a dropfile. Linux always uses stdio for BBS I/O regardless of this flag; see "Deployment on Synchronet"'s I/O Method note. |
| `-L` | `--local` | Local test mode: skip dropfile search entirely and start as GUEST over stdio — useful for a sysop testing the binary by hand before wiring it into the BBS at all |

These combine with a normal dropfile: for example `run.sh %f -N2` loads
the dropfile from `%f` as usual but forces node 2 regardless of what the
dropfile itself says. Everything the door decides — which flags fired,
what got overridden, which transport it ended up using — is logged to
`/tmp/sixeldoor.log` (Linux) / `%TEMP%\sixeldoor.log` (Windows).

## Video rendering

Two paths, same probe-then-pick pattern as audio. The door tries JPEG XL
first and falls back to sixel; check the log for `video renderer: jxl` or
`video renderer: sixel` to see which a given session got.

### JPEG XL (preferred)

SyncTERM's CTerm core can draw JPEG XL images directly, as an alternative
to sixel. Measured against a real frame at 440×320: a dithered, 16-color
sixel frame (the *cheapest* sixel option) is 90KB; a visually-clean JPEG
XL frame is about 17-20KB — full 24-bit color, no palette, no banding.
That's not a quality/bandwidth trade-off, it's strictly better on both
axes.

Used only when both sides support it, checked once per connection: the
client's CTerm build (reverts to sixel if the terminal doesn't answer),
and this door's own ffmpeg (checked once at startup — most distro ffmpeg
packages don't enable `libjxl` by default; check
`ffmpeg -encoders | grep libjxl` before assuming it's there. No
JXL-specific runtime dependency beyond ffmpeg itself if it's compiled in).

### Sixel (fallback)

The original path. Used for any client that doesn't answer the JXL query
(older SyncTERM, or any of the non-SyncTERM sixel-capable terminals: foot,
WezTerm, `xterm -ti 340`, mlterm — none of those speak the JPEG XL
extension), or if this door's own ffmpeg lacks libjxl support.

### Video running in slow motion / audio finishing before video

Each renderer targets a fixed frame rate (sixel: 5fps, JXL: 10fps) —
ffmpeg samples that many frames per second of *source* video, and every
frame has to actually reach the client before the door moves to the next
one. If a connection can't deliver frames that fast, the video doesn't
drop frames to catch up — it just takes longer than the source's real
length to play, i.e. slow motion. Since audio is paced by its own wall
clock (deliberately independent of video, for reliability — see the main
README if you want the full reasoning), a video that's playing in slow
motion will fall behind and finish noticeably after the audio ends.

There's no way to predict a given box's or connection's actual
sustainable delivery rate in advance — it depends on network throughput,
encode speed on that specific hardware, and the client's own rendering
speed. Measure it from that deployment's own log instead: frame count
(logged as `player: stopped after N frames`) divided by wall-clock
playback duration (from the first-frame timestamp to that stop line).
Then set the target to match, via `run.sh`/`run.bat`:

    SIXELDOOR_SIXEL_FPS=5      # default; lower if measured delivery is below this
    SIXELDOOR_JXL_FPS=10       # default; lower if measured delivery is below this

Dropping the target to match measured delivery doesn't make frames
arrive faster — it makes the number of *sampled* source frames match what
can actually be delivered in real time, so playback duration matches the
source's real length again instead of running slow, and audio/video stay
in sync.

**Don't set either one higher than your deployment can actually deliver —
it causes the exact same slow-motion/desync symptom, just from your own
connection or hardware being the bottleneck instead of the default being
wrong for it.** ffmpeg always samples source video at whatever the target
says, whether or not that many frames a second can actually reach the
client; setting the number too high doesn't fail loudly, it just quietly
reproduces the slow-motion effect this section is about.

There's no universal "safe" number — it depends entirely on that specific
connection's bandwidth and that specific box's CPU — but real deployments
so far have landed roughly in these ranges, useful as a starting point and
a sanity check on a new measurement:

- **Sixel**: 3-8fps. The current default (5) came from one real
  deployment's measured ceiling.
- **JXL**: 4-10fps. The current default (10) is untuned for a slow
  connection/box; one real Windows deployment measured 4-7fps actual
  against it.

Tune by raising or lowering from the default in small steps, replaying
the same video, and re-measuring actual delivered fps from the log each
time (same frame-count ÷ wall-clock-duration method as above) — stop
raising once the measured number stops keeping up with the target, that's
this deployment's real ceiling. Also not much reason to go below ~2fps
even on a very constrained connection — below that it stops reading as
video at all, more like a slideshow.

### Getting a libjxl-enabled ffmpeg

Most distro ffmpeg packages don't ship a `libjxl` encoder — FFmpeg didn't
gain one until version 6.1 (late 2023), newer than what most LTS distros
carry, and even 6.1+ packages often don't enable it by default. Confirmed
on a real deployment: Ubuntu 22.04's stock `ffmpeg 4.4.2` has no libjxl at
all.

Easiest fix on any platform: grab a static build with `libjxl` already
compiled in from [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds)
— no source compile, no distro package wrangling, and its `-gpl` builds
also include `libmp3lame`/`libopus`/`libvorbis` (all three audio encoders
this door needs — see Audio, below).

**Linux:**

    wget https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz
    tar xf ffmpeg-master-latest-linux64-gpl.tar.xz
    sudo cp ffmpeg-master-latest-linux64-gpl/bin/ffmpeg /usr/local/bin/ffmpeg
    sudo chmod 755 /usr/local/bin/ffmpeg

The door checks `/usr/local/bin/ffmpeg` *before* `/usr/bin/ffmpeg`, so
dropping the new binary there is enough to take over from a distro
package — no `$PATH` or `run.sh` edit needed. Confirm with
`ffmpeg -encoders | grep libjxl`, then restart the door (it caches the
libjxl check once at startup, so an already-running instance won't pick up
a newly-installed ffmpeg until restarted). Use `chmod 755`, not a bare
`chmod +x` — under a restrictive umask (common when run via `sudo`), `+x`
alone can leave the file unreadable by anyone but root, which breaks it
for the door's own (non-root) process with an unrelated-looking error.

**Windows:** download `ffmpeg-master-latest-win64-gpl.zip` from the same
[releases page](https://github.com/BtbN/FFmpeg-Builds/releases), unzip it,
and copy `bin\ffmpeg.exe` to wherever `run.bat`'s `FFMPEG_DIR` points (by
default, next to `sixeldoor.exe`). Verify with
`ffmpeg -encoders | findstr libjxl`. (32-bit Windows: BtbN doesn't publish
32-bit Windows builds; without one, 32-bit deployments stay on sixel,
which is fully functional — see Sixel, above.)

## Stream library

Alongside search and pasting a URL/video ID, users can pick from a small
persisted list of saved streams — a "channel guide" that survives between
sessions, curated by whoever's allowed to edit it. Idea and original
suggestion: MeaTLoTioN of thE qUAntUm wOrmhOlE BBS (see Credits).

At the main prompt:

- `L` / `lib` / `library` — shows the saved list (same numbered
  channel-guide UI as search results — press `1`-`9`/`0` to play, `Q` to
  back out) and plays whichever entry you pick.
- `A` / `add` — prompts for a URL or video ID, resolves its title via
  yt-dlp automatically, and appends it to the library.
- `E` / `edit` — shows the library, lets you pick an entry, then
  `D`elete, `R`ename, or give it a new `U`RL (title re-resolves
  automatically).

Viewing and playing from the library is open to everyone. Adding and
editing are gated behind a minimum security level — `[A]dd`/`[E]dit`
only even *appear* in the status-row hint for users who qualify, so the
door never advertises a command that'd just bounce with a permission
error. Configure the threshold (BBS packages' numeric security-level
scales vary; Synchronet sysops commonly sit around 90-100) by editing
`run.sh`/`run.bat` to export:

    SIXELDOOR_LIBRARY_EDIT_SECLEVEL=90     # default; matches the user's level from door32.sys/DOOR.SYS

If a user reports being denied who shouldn't be, check
`/tmp/sixeldoor.log` for a line like `library: add denied — secLevel=X,
need Y` — it logs both values whenever a permission check fails, so a
mismatch is immediately visible instead of guesswork.

Storage is a flat JSON file, human-readable and hand-editable if you'd
rather manage it that way — `[{"title": "...", "url": "..."}, ...]`.
Defaults to `streams.json` next to the running binary; override by
exporting in `run.sh`/`run.bat`:

    SIXELDOOR_LIBRARY_PATH=/path/to/streams.json

### Mouse support: channel up/down buttons

Two small glyphs in the TV art (right side panel, above MeaTLoTioN's "mL"
signature plate) are clickable "channel" buttons: click one, and the door
jumps to the previous/next stream library entry and starts playing it
immediately, TV-remote style — an even faster path than opening
`[L]ibrary` and picking from the list. **Up/Down arrow keys do the exact
same thing**, as a keyboard-only equivalent for clients without mouse
support.

This rides on standard XTerm-compatible mouse tracking, not a
SyncTERM-specific extension, and it's purely additive: a client that
doesn't support mouse reporting simply never sends the sequences the door
watches for, so nothing about keyboard-only behavior changes for it.

If the library is empty when a button's pressed, the status row shows a
brief "library is empty" message instead of doing nothing silently.

Idea and button placement in the art: MeaTLoTioN of thE qUAntUm
wOrmhOlE BBS (see Credits).

## Audio

Two tiers, tried in order. The door picks one per connection and shows which
on the status row ("SOUND: WAV" / "AUDIO <url>" / "SILENT").

### 1. Native SyncTERM 1.10+ audio (primary)

SyncTERM 1.10+ exposes a real PCM audio mixer to BBS doors over an
Application Program Command (APC) protocol, documented at
[syncterm.net/cterm.html, "Audio APCs"](https://syncterm.net/cterm.html).
This door uses it directly: on connect it probes which audio
container/codec the client's libsndfile build can decode (preference
order **WAV/PCM16 → Opus → Vorbis → MP3**), then streams short
independently-decodable chunks of the transcoded YouTube audio track,
queued onto the terminal's own mixer so playback timing rides the
terminal's sample clock rather than network speed.

WAV is preferred over the much smaller compressed formats specifically
because each chunk must be independently decodable — that resets a
compressed codec's internal state at every chunk boundary, audible as
periodic crackling. Raw PCM has no such state to reset. If your
deployment is bandwidth-constrained enough that WAV isn't viable, a
compressed fallback still works, just with that periodic click.

If the client doesn't have SyncTERM 1.10+ compiled with libsndfile
support, or never answers the capability probe within 2 seconds, the
door falls through to tier 2 instead of going silent.

### 2. Browser audio (fallback)

For everyone else — older SyncTERM, or any other sixel-capable terminal —
the door stands up a small HTTP server that transcodes the audio track to
MP3 on the fly and shows a URL on screen to open on a phone or second
window, synced to skip ahead to wherever the video already is rather than
always restarting from 0:00.

Requires `SIXELDOOR_HOSTNAME` (and optionally `SIXELDOOR_AUDIO_PORT`,
default 18080) set in `run.sh`/`run.bat` — **this must be your BBS's
actual public hostname or IP**, reachable from outside your LAN, since
users open this link on their own phone/second device. Without a
reachable hostname configured, this tier is unavailable too and playback
is silent for anyone who lands on it. Open `SIXELDOOR_AUDIO_PORT` in your
firewall.

ANSI Music (short power-on/channel-change/error jingles) is unrelated to
both tiers — enable **"All ANSI Music enabled"** in SyncTERM → Setup →
ANSI Music to hear them.

## Fixing intermittent HTTP 403 errors (YouTube PO Tokens)

**Do this once as part of initial setup, not just when something breaks
later:** right after installing yt-dlp (whether via `install-bbs-deps.sh`
or by hand), run `update-yt-dlp-nightly.sh` (Linux) /
`update-yt-dlp-nightly.bat` (Windows) once, by hand, before wiring the
door into your BBS at all. The **stable** PyPI/release build you just
installed can already be broken against current YouTube — confirmed live:
a fresh install failed with `HTTP Error 403: Forbidden` on 100% of videos
until the nightly script was run, at which point it worked immediately.
Finding that out now, with a direct command, is a lot faster than
discovering it later while debugging what looks like a door or BBS
configuration problem. See "When it's not noise" below for the exact
symptom this fixes, and how to keep it fixed going forward.

Even with a current yt-dlp and a working JS runtime, expect occasional
`ERROR: unable to download video data: HTTP Error 403: Forbidden` in the
door's log — look for `player: attempt N/5 produced no frames, retrying`
right after it; the door already retries each pipeline up to 5 times,
which absorbs most of this on its own. The underlying cause: YouTube
increasingly requires a **Proof-of-Origin (PO) Token** on the actual media
fetch, and yt-dlp has no PO token provider configured by default. Confirm
this is what you're seeing with `yt-dlp -v <url> 2>&1 | head -20` —
`[pot] PO Token Providers: none` means yes.

**Reduce it:** install the
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
plugin, which supplies tokens on yt-dlp's behalf:

    pip install --user --upgrade bgutil-ytdlp-pot-provider
    cd ~
    git clone https://github.com/Brainicism/bgutil-ytdlp-pot-provider.git
    cd bgutil-ytdlp-pot-provider/server
    deno install

Run its server mode (`deno run -A src/main.ts`, listens on `:4416`) as a
real service so it survives reboots:

**Linux (systemd):**

    sudo tee /etc/systemd/system/bgutil-pot-provider.service > /dev/null <<'EOF'
    [Unit]
    Description=bgutil yt-dlp PO Token Provider
    After=network.target

    [Service]
    Type=simple
    User=YOUR_BBS_USER
    WorkingDirectory=/home/YOUR_BBS_USER/bgutil-ytdlp-pot-provider/server
    ExecStart=/home/YOUR_BBS_USER/.deno/bin/deno run -A src/main.ts
    Restart=on-failure
    RestartSec=5

    [Install]
    WantedBy=multi-user.target
    EOF
    sudo systemctl daemon-reload
    sudo systemctl enable --now bgutil-pot-provider
    curl -s http://127.0.0.1:4416/ping   # confirms it's up

**Windows:** install it as a service with [NSSM](https://nssm.cc/):

    nssm install bgutil-pot-provider "C:\Users\you\.deno\bin\deno.exe" "run -A src\main.ts"
    nssm set bgutil-pot-provider AppDirectory "C:\Users\you\bgutil-ytdlp-pot-provider\server"
    nssm start bgutil-pot-provider

or, for a small/personal BBS, drop a shortcut to
`deno.exe run -A src\main.ts` (with "Start in" set to the `server`
folder) into the `shell:startup` folder so it launches on login.

No further configuration is needed — yt-dlp auto-detects the running
server on `127.0.0.1:4416`.

**Managing expectations:** this closes some but not all of the gap —
single-attempt success commonly hovers around 50-80% whether or not a PO
token provider is configured, which looks like inherent noise in
YouTube's server-side bot-scoring. The door's built-in 5x retry is what
actually delivers a reliable experience in practice; treat the PO token
provider as a modest improvement layered on top of that, not a full fix.

### When it's not noise: a real yt-dlp extractor break, and how to recover

The 50-80%-with-retries picture above describes *normal* background
noise. Separately, yt-dlp **stable** releases can go from that normal
noise to **0% success on every video, every attempt**, for weeks at a
time — a genuine YouTube-side extractor break that no PO token provider
or retry logic can paper over. This is a widely-reported, actively-tracked
category of problem (e.g.
[yt-dlp/yt-dlp#17368](https://github.com/yt-dlp/yt-dlp/issues/17368)).

**How to tell the difference:** if `player: attempt N/5 produced no
frames, retrying` shows up but full playback still succeeds sometimes,
that's the normal 50-80% noise above — no action needed. If *every single
attempt, on every video, always* fails (test directly:
`yt-dlp -v <any recent YouTube URL>` a few times in a row — 0/5 or worse
is the signal), that's this category of problem instead.

**Fix it, and keep it fixed going forward:** this package ships
`update-yt-dlp-nightly.sh` (Linux) / `update-yt-dlp-nightly.bat` +
`update-yt-dlp-nightly.ps1` (Windows) — standalone scripts, not run
automatically by anything. How and when they run is your decision; see
each script's own header comment for scheduling examples (systemd timer,
cron, Windows Task Scheduler, or your BBS software's own event
scheduler).

Run it once by hand first, to fix a break immediately and confirm it
works before scheduling anything:

    sudo ./update-yt-dlp-nightly.sh          # Linux — needs root, writes to /usr/local/bin
    update-yt-dlp-nightly.bat                # Windows

This replaces yt-dlp with the current **nightly** build — as a
**standalone binary**, not a `pip install`, so it works regardless of
which account actually launches the door (a dedicated BBS service account
is common, and different from whichever account a sysop happens to be
logged in as — a `pip install --user` fix only affects the account that
ran it). Confirm the door picked it up via the `yt-dlp=... (version ...)`
line logged at door startup.

Once confirmed working, schedule it (daily is a reasonable starting
cadence) — example cron line (root's own crontab, so no `sudo` is needed
inside the line itself):

    17 3 * * * /path/to/update-yt-dlp-nightly.sh >> /var/log/update-yt-dlp-nightly.log 2>&1

Trade-off, stated plainly: nightly is explicitly not the vetted-stable
channel, and an occasional nightly build could introduce its own
regression. Scheduling this trades that small, usually-short-lived risk
for not being stuck fully broken against YouTube for weeks at a time. If
a nightly update ever makes things worse: Linux, `pip3 install --upgrade
yt-dlp` (add `--break-system-packages` if needed) gets you a stable
build back — you'll then need to manually place that where
`update-yt-dlp-nightly.sh`'s target expects it, or just re-point your
`$PATH`; Windows, grab the current stable `yt-dlp.exe` from
https://github.com/yt-dlp/yt-dlp/releases/latest and swap it back in the
same way you installed the nightly one. Either way, investigate before
re-enabling the schedule.

### Windows 7: official yt-dlp will not run at all, and it's not a hotfix problem

If you're on genuine Windows 7 (either 32-bit or 64-bit — this is not an
architecture issue), stop before chasing missing-DLL errors: **official
yt-dlp requires Python 3.9+, which requires Windows 8 or later.** This is
a hard, intentional upstream limitation
([yt-dlp/yt-dlp#10086](https://github.com/yt-dlp/yt-dlp/issues/10086)),
not a missing hotfix — confirmed the hard way, by tracing the actual
Windows Update manifests: neither KB2999226 nor KB2533623 (the two
updates most commonly cited online as "the fix" for this) actually
provide the specific API set (`api-ms-win-core-path-l1-1-0.dll`) that
Python 3.9+ needs, because Microsoft never backported it to Windows 7 at
all. No Windows Update, VC++ Redistributable, or .NET Framework version
fixes this — the last **official** yt-dlp release that runs on Windows 7
is permanently frozen at `2024-10-31` and gets no further updates against
YouTube's own changes.

**The win7 packages (`sixeldoor-win7-x32`/`sixeldoor-win7-x64`) route
around this automatically**: their `update-yt-dlp-nightly.ps1` points at
[nicolaasjan/yt-dlp](https://github.com/nicolaasjan/yt-dlp) instead of
official yt-dlp — a third-party fork that specifically keeps building for
Windows 7 (and XP) and tracks upstream's own fixes closely (its release
tags use the same dated versioning as upstream's nightly channel, and as
of writing its latest release was published the same day as this
paragraph was written). This is **not** the official yt-dlp project —
it's one maintainer's fork; a reasonable, working choice for keeping an
otherwise end-of-support OS running, but a real trust decision, not
Microsoft- or yt-dlp-project-endorsed. If you'd rather not run a
third-party fork, download it yourself and swap it in by hand:
`yt-dlp_x86_win7.exe` (32-bit) or `yt-dlp_win7.exe` (64-bit) from
[nicolaasjan/yt-dlp's releases](https://github.com/nicolaasjan/yt-dlp/releases/latest),
renamed to `yt-dlp.exe`, or pin to official yt-dlp's last working release
instead and accept that it will eventually drift out of sync with
YouTube with no further fix available.

The regular `win32`/`win64` packages (non-Windows-7) are unaffected and
keep using official yt-dlp — this only applies to genuine Windows 7.

## Security considerations

- yt-dlp is a network-facing program that parses hostile input (YouTube's
  responses). Keep it updated.
- ffmpeg takes arbitrary bytes from yt-dlp's stdout. Not strictly hostile
  since yt-dlp validates, but you're still exposing ffmpeg's demuxers to
  the internet.
- Each session spawns its own yt-dlp + ffmpeg processes (one pair for video,
  a second pair for audio). A sysop running this publicly should impose
  concurrency limits (Synchronet's "Maximum Concurrent Users" field, or
  your BBS software's equivalent) and watch CPU.
- A dropped carrier or a killed session doesn't leave those yt-dlp/ffmpeg
  processes running: the door detects a dead connection (via a read error
  on its input) and shuts down its own video/audio pipelines, and also
  responds to SIGINT/SIGTERM/SIGHUP by killing every pipeline it has
  running before exiting — so however your BBS ends a session (closing the
  socket, a plain `kill`, or killing the door's whole process group), the
  child processes go with it rather than lingering.
- The browser-audio fallback server is a plain HTTP listener with no
  auth — tokens are random but the endpoint itself is reachable by anyone
  who can hit the port. It only relays a transcode of a YouTube URL the
  door itself chose, so the exposure is bandwidth/CPU, not arbitrary
  command execution. Keep `SIXELDOOR_AUDIO_PORT` behind the same firewall
  posture as the rest of the BBS.

## Credits

- **sixeldoor** itself — by **StingRay (Jerry Reed)** of **A-Net Online
  BBS** / **ANetSoftware**.
  - Web: https://a-net.online
  - Telnet: telnet://bbs.a-net.online:1337
  - SSH: ssh://bbs.a-net.online:1338
  - GitHub: https://github.com/anetonline
- **TV cabinet ANSI art** and the **SyncTERM client build script**
  (`ml-syncterm-install.sh`) — originally by **MeaTLoTioN of thE qUAntUm
  wOrmhOlE BBS**.
- **Stream library feature and mouse-clickable channel buttons** — idea,
  original suggestion, and button placement in the art by **MeaTLoTioN of
  thE qUAntUm wOrmhOlE BBS**.

## License

MIT, do as you please.
