A proxy pointed at 127.0.0.1 from inside a container reaches the
container itself, and the only sign was a connection error buried in the
first search. Startup now probes the proxy over TCP and says what is
wrong, naming host.docker.internal when loopback was configured.
The README also covers the follow-up trap: even that address fails when
the proxy listens on loopback only, so it shows how to check the bind
address and what to change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
YTDLP_PROXY routes every yt-dlp call — search, metadata and the audio
stream, for YouTube and SoundCloud alike — through an http(s) or SOCKS
proxy such as a local Psiphon. Startup logs which proxy is in use with
any credentials stripped.
ffmpeg has no SOCKS support, so with a proxy configured playback always
goes through the yt-dlp pipe instead of a resolved CDN URL: nothing
escapes past the proxy, at the cost of slower seeking. Direct links keep
using ffmpeg, which gets the proxy only when it speaks http(s).
Verified against a dead proxy: requests fail through it rather than
quietly going direct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A link copied from an open mix (watch?v=…&list=RD…) expanded into the
whole radio station — 500 entries from one paste. Any URL with a `v=`
parameter now resolves to that single track, and only a /playlist?list=…
URL expands. The rule is deliberately blunt rather than keyed on
start_radio, so pasting a link behaves the same way every time.
Playlist expansion is also capped separately from the queue limit
(MAX_PLAYLIST_TRACKS, 100 by default) and the chat says when a playlist
hit that ceiling.
Verified against the real yt-dlp: both mix links resolve to one track, a
plain video link to one, and a playlist URL to its 13 entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Errors reached people as raw tool output: "Unsupported URL:
https://...%D0%B0%D1%83..." says nothing to whoever pasted a link. The
common cases now map to plain descriptions with the next step where there
is one (age gate and bot checks point at YTDLP_COOKIES, a stale extractor
at updating yt-dlp), URLs are decoded, and anything unrecognised still
falls through verbatim.
The panel's prefix hint is gone now that the source picker does the same
job visibly; prefixes keep working and stay documented for chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Search now queries YouTube and SoundCloud together by default and
interleaves the two result lists so neither buries the other; a picker
left of the input narrows it to one source (plus the local library when
configured), and a prefix typed into the query still outranks it.
The panel was capped at 1180px, which left most of a wide screen empty —
it now scales to 1680px and gives the search column the extra room.
A dead link also reported "could not parse yt-dlp's response", which
described our parser rather than the problem; it now shows yt-dlp's own
error line, or says the link did not open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things people hit while using the panel:
- The bot could be sent into a channel the requester was not in, and
playback could be started from nowhere. Playback now follows the
listener (REQUIRE_LISTENER, on by default), and the voice row shows
where you and the bot are instead of offering a free channel picker.
- Voice presence only refreshed on reload, because the SDK updates
channel participants without emitting an event. The socket now watches
that view and pushes changes.
- The bot left the channel whenever the queue ran dry. It now leaves only
after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by
default), and stays put while anyone is still listening.
- A search that yielded nothing said nothing: yt-dlp can exit 0 with an
empty result, so that case now reports the reason (or "nothing found"),
and searches are logged with their result count.
The queue moved under the player so search owns the left column, and
elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a
live stream, which also affected the chat's progress bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The image shipped a year-old yt-dlp, which YouTube now rejects with
"The page needs to be reloaded". Bumped to 2026.08.19 and documented
rebuilding as the standard fix, including how to pass a newer tag
without waiting for a repository update.
A downloader dying mid-stream also looked exactly like a very short
track: ffmpeg saw EOF, the player advanced, and the channel only got
"queue finished". The failure reason now reaches the chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cookies were already wired up but only mentioned in passing, and the
non-obvious parts were undocumented: yt-dlp has no username/password
support for YouTube, it rewrites the cookie file to persist rotated
cookies (so a read-only file expires early), and the export has to
happen in a private window that is logged out before closing.
Startup now reports whether the cookie file is usable, missing, or
read-only, and YTDLP_EXTRACTOR_ARGS is passed through for the cases
where YouTube blocks a server IP outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plays audio into Stoat voice channels over LiveKit and exposes the same
player through both chat commands and a browser panel, so the two never
drift apart: everything routes through a single MusicManager.
- core: per-server GuildPlayer (queue, loop, shuffle, seek, volume,
idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg
- sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet
radio, optional local library with path-traversal guards
- bot: 18 chat commands with aliases, plus !panel one-time login links
- api: Fastify REST + WebSocket, sessions authenticated against the
instance's own /auth/session/login (TOTP supported), permissions
re-checked against Stoat membership and roles on every request
- web: React panel with search, queue editing, seek and volume
- deploy: Dockerfile, compose.override.yml and Caddyfile snippets for
dropping the service into an existing /opt/stoat stack
Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs
(9 API checks). Voice playback itself needs a live instance to test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>