Make video a per-server toggle instead of an env-wide setting

VIDEO_ENABLED is now permission rather than behaviour: it decides whether
the feature exists at all, while turning it on for a server is a toggle in
the panel or !video in chat, off by default. Video costs real CPU for
every playing channel, so that should be a deliberate choice rather than
something a config flag switches on everywhere.

With the env flag off the panel renders no toggle at all and !video says
so, and the switch applies from the next track — swapping tracks mid-play
would cut the current one.

The loop button no longer reads "выкл" either: it sat next to the video
button showing the same word, so the two states were indistinguishable.
Both now name what they do and rely on highlighting for state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:13:08 +03:00
co-authored by Claude Opus 5
parent e06c42f60c
commit a9b680c418
11 changed files with 92 additions and 13 deletions
+2
View File
@@ -79,6 +79,8 @@ YTDLP_JS_RUNTIME=node
# Требует прав Video у бота и включённого видео в конфигурации инстанса.
# YouTube отдаёт одним файлом только 360p, а стримить можно лишь такой формат —
# отсюда размер по умолчанию. Кодирование видео заметно грузит CPU сервера.
# Это лишь разрешение: сам показ включается тумблером в панели или командой
# !video, и по умолчанию выключен. При false тумблер в панели не показывается.
VIDEO_ENABLED=false
VIDEO_WIDTH=640
VIDEO_HEIGHT=360
+7 -2
View File
@@ -125,6 +125,7 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
| `!video [on\|off]` | показывать клип как демонстрацию экрана |
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
| `!help` | список команд |
@@ -227,8 +228,12 @@ docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compo
## Видео: клип как демонстрация экрана
`VIDEO_ENABLED=true` — и вместе со звуком бот публикует картинку клипа отдельной дорожкой
(screen share), которую видно в голосовом канале.
`VIDEO_ENABLED=true` разрешает боту публиковать картинку клипа отдельной дорожкой (screen
share), которую видно в голосовом канале. Само включение — тумблер **📺 видео** в панели или
команда `!video on|off`, и по умолчанию оно выключено: видео стоит дороже звука, поэтому платить
за него нужно осознанно. Переключение применяется со следующего трека — менять дорожки на
лету значило бы прервать текущий. При `VIDEO_ENABLED=false` тумблер в панели не показывается
вовсе, а `!video` отвечает, что видео выключено в настройках.
```dotenv
VIDEO_ENABLED=true
+3
View File
@@ -186,6 +186,7 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
features: {
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
requireListener: config.REQUIRE_LISTENER,
video: config.VIDEO_ENABLED,
},
});
});
@@ -265,6 +266,8 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
leave: (serverId, userId) => manager.leave(serverId, userId),
volume: (serverId, userId, body) =>
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
video: (serverId, userId, body) =>
manager.setVideo(serverId, userId, z.object({ enabled: z.boolean() }).parse(body).enabled),
loop: (serverId, userId, body) =>
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
seek: (serverId, userId, body) =>
+23 -1
View File
@@ -260,6 +260,26 @@ export const commands: Command[] = [
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
},
},
{
name: "video",
aliases: ["видео"],
usage: "video [on|off]",
description: "Показывать клип как демонстрацию экрана",
async run(ctx) {
if (!config.VIDEO_ENABLED) {
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
}
const raw = (ctx.args[0] ?? "").toLowerCase();
const current = ctx.manager.snapshot(ctx.serverId).videoEnabled;
const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current;
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next);
await ctx.reply(
next
? "📺 Клип будет показан как демонстрация экрана — со следующего трека."
: "🔇 Видео выключено, играю только звук.",
);
},
},
{
name: "shuffle",
aliases: ["sh", "перемешай"],
@@ -348,7 +368,9 @@ export const commands: Command[] = [
usage: "help",
description: "Показать список команд",
async run(ctx) {
const lines = commands.map(
const lines = commands
.filter((command) => command.name !== "video" || config.VIDEO_ENABLED)
.map(
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\`${command.description}`,
);
await ctx.reply(
+5
View File
@@ -269,6 +269,10 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
(await this.require(serverId, userId)).setVolume(volume);
}
async setVideo(serverId: string, userId: string, enabled: boolean): Promise<void> {
(await this.require(serverId, userId)).setVideo(enabled);
}
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
(await this.require(serverId, userId)).setLoop(mode);
}
@@ -314,6 +318,7 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
volume: config.DEFAULT_VOLUME,
loop: "off",
shuffleUsed: false,
videoEnabled: false,
updatedAt: Date.now(),
};
}
+12 -3
View File
@@ -66,7 +66,9 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
current: Track | null = null;
volume = config.DEFAULT_VOLUME;
loop: LoopMode = "off";
shuffleUsed = false;
shuffleUsed = false;
/** Per-server switch for publishing the clip; the env flag gates it too. */
videoEnabled = false;
private status: PlayerStatus = "idle";
private readonly revoice: RevoiceLike;
@@ -113,7 +115,8 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
history: this.history.slice(0, 10),
volume: this.volume,
loop: this.loop,
shuffleUsed: this.shuffleUsed,
shuffleUsed: this.shuffleUsed,
videoEnabled: this.videoEnabled,
updatedAt: Date.now(),
};
}
@@ -307,7 +310,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.publish();
try {
const input = await openPlayback(track, seekSeconds);
const input = await openPlayback(track, seekSeconds, { video: this.videoEnabled });
this.currentInput = input;
this.expectingStop = false;
await media.playStream(input.input, input.inputOptions);
@@ -464,6 +467,12 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.publish();
}
/** Takes effect on the next track: switching mid-stream would cut playback. */
setVideo(enabled: boolean): void {
this.videoEnabled = enabled;
this.publish();
}
setLoop(mode: LoopMode): void {
this.loop = mode;
this.publish();
+16 -4
View File
@@ -173,9 +173,14 @@ export interface PlaybackInput {
video?: { stream: Readable; width: number; height: number };
}
export interface PlaybackOptions {
/** Publish the picture too; the server-wide switch still has to allow it. */
video?: boolean;
}
/** Only YouTube reliably carries a picture worth showing next to the audio. */
function canShowVideo(track: Track): boolean {
return config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive;
function canShowVideo(track: Track, wanted: boolean): boolean {
return wanted && config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive;
}
const HTTP_RESILIENCE = [
@@ -196,7 +201,11 @@ function ffmpegProxyOptions(): string[] {
}
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> {
export async function openPlayback(
track: Track,
seekSeconds = 0,
options: PlaybackOptions = {},
): Promise<PlaybackInput> {
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
if (track.source === "local") {
@@ -214,7 +223,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise<Playb
// The format is checked before committing to the video pipeline: audio comes
// out of that same pipeline, so falling back afterwards would kill the sound.
if (canShowVideo(track) && (await ytdlp.hasProgressiveVideo(track.url, config.VIDEO_HEIGHT))) {
if (
canShowVideo(track, options.video ?? false) &&
(await ytdlp.hasProgressiveVideo(track.url, config.VIDEO_HEIGHT))
) {
log.info({ title: track.title }, "playing with video");
const pipeline = openVideoPipeline(track.url, seekSeconds);
return {
+2 -1
View File
@@ -42,7 +42,8 @@ export interface PlayerSnapshot {
history: Track[];
volume: number;
loop: LoopMode;
shuffleUsed: boolean;
shuffleUsed: boolean;
videoEnabled: boolean;
updatedAt: number;
}
+1
View File
@@ -183,6 +183,7 @@ export function App() {
voiceChannels={voiceChannels}
yourVoiceChannel={yourVoiceChannel}
requireListener={me.features.requireListener}
videoAvailable={me.features.video}
onAction={(action, payload) => void runAction(action, payload)}
onSeek={(seconds) => void runAction("seek", { position: seconds })}
onJoin={(channelId) => {
+19 -1
View File
@@ -9,6 +9,8 @@ interface Props {
voiceChannels: VoiceChannel[];
yourVoiceChannel: VoiceChannel | null;
requireListener: boolean;
/** Hidden entirely when the bot was built without video support. */
videoAvailable: boolean;
onAction(action: string, payload?: Record<string, unknown>): void;
onSeek(seconds: number): void;
onJoin(channelId: string | null): void;
@@ -23,7 +25,7 @@ const STATUS_LABEL: Record<PlayerState["status"], string> = {
};
const LOOP_LABEL: Record<LoopMode, string> = {
off: "🔁 выкл",
off: "🔁 повтор",
track: "🔂 трек",
queue: "🔁 очередь",
};
@@ -35,6 +37,7 @@ export function NowPlaying({
voiceChannels,
yourVoiceChannel,
requireListener,
videoAvailable,
onAction,
onSeek,
onJoin,
@@ -141,6 +144,21 @@ export function NowPlaying({
{LOOP_LABEL[state.loop]}
</button>
{videoAvailable && (
<button
className={state.videoEnabled ? "active" : ""}
onClick={() => onAction("video", { enabled: !state.videoEnabled })}
disabled={!canControl}
title={
state.videoEnabled
? "Клип показывается как демонстрация экрана — нажмите, чтобы выключить"
: "Показывать клип как демонстрацию экрана (со следующего трека)"
}
>
📺 видео
</button>
)}
<div className="volume">
<span title="Громкость">🔊</span>
<input
+2 -1
View File
@@ -26,6 +26,7 @@ export interface PlayerState {
history: Track[];
volume: number;
loop: LoopMode;
videoEnabled: boolean;
updatedAt: number;
}
@@ -43,7 +44,7 @@ export interface ServerRef {
export interface Me {
user: { id: string; username: string };
servers: ServerRef[];
features: { localLibrary: boolean; requireListener: boolean };
features: { localLibrary: boolean; requireListener: boolean; video: boolean };
}
export interface ServerStateResponse {