Update bundled yt-dlp and report source failures in chat

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>
This commit is contained in:
Leonid Pershin
2026-09-08 23:45:25 +03:00
co-authored by Claude Opus 5
parent 823a9f1565
commit 315760e076
5 changed files with 341 additions and 293 deletions
+4 -1
View File
@@ -28,7 +28,10 @@ ENV HOME=/tmp
WORKDIR /app WORKDIR /app
# yt-dlp_linux is a self-contained binary, so no Python runtime is needed. # yt-dlp_linux is a self-contained binary, so no Python runtime is needed.
ARG YTDLP_VERSION=2025.08.20 # YouTube breaks extractors regularly, so keep this current: rebuilding with
# --build-arg YTDLP_VERSION=<tag> (or bumping this default) is the usual fix for
# "The page needs to be reloaded" and similar extraction errors.
ARG YTDLP_VERSION=2026.08.19
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \ && apt-get install -y --no-install-recommends ca-certificates curl \
&& curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \ && curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \
+18
View File
@@ -188,6 +188,24 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже. - `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`. - `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
## Обновление yt-dlp
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
логах — `ERROR: [youtube] ...: The page needs to be reloaded` или подобное. Лечится обновлением
yt-dlp: версия зашита в [Dockerfile](Dockerfile) как `ARG YTDLP_VERSION`.
```bash
cd /opt/stoat-mbot && git pull && docker compose up -d --build
```
Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:
```bash
docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d
```
Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases).
## Учётка YouTube (cookies) ## Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
+8
View File
@@ -276,6 +276,14 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
// stop() rebuilds the volume transformer, so volume is applied per track. // stop() rebuilds the volume transformer, so volume is applied per track.
media.setVolume(this.volume / 100); media.setVolume(this.volume / 100);
this.startTicker(); this.startTicker();
// A downloader that dies mid-stream just looks like a very short track, so
// say why instead of silently moving on.
void input.failure?.then((reason) => {
if (!reason || this.current?.id !== track.id) return;
this.log.warn({ reason, track: track.title }, "source failed while streaming");
this.notify(`⚠️ **${track.title}** — источник отдал ошибку: ${reason}`);
});
} catch (err) { } catch (err) {
this.log.warn({ err, track: track.title }, "playback failed"); this.log.warn({ err, track: track.title }, "playback failed");
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка"; const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
+8 -1
View File
@@ -110,6 +110,8 @@ export interface PlaybackInput {
input: string | Readable; input: string | Readable;
inputOptions: string[]; inputOptions: string[];
cleanup(): void; cleanup(): void;
/** Resolves with a reason if the downloader died on its own, for reporting. */
failure?: Promise<string | null>;
} }
const HTTP_RESILIENCE = [ const HTTP_RESILIENCE = [
@@ -139,5 +141,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise<Playb
} }
const proc = ytdlp.openAudioStream(track.url); const proc = ytdlp.openAudioStream(track.url);
return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() }; return {
input: proc.stream,
inputOptions: [],
cleanup: () => proc.kill(),
failure: proc.failure,
};
} }
+15 -3
View File
@@ -209,6 +209,8 @@ export async function resolveStreamUrl(pageUrl: string): Promise<string> {
export interface AudioProcess { export interface AudioProcess {
stream: Readable; stream: Readable;
kill(): void; kill(): void;
/** Resolves with a reason when the download fails, or null when it was fine. */
failure: Promise<string | null>;
} }
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */ /** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
@@ -220,19 +222,29 @@ export function openAudioStream(pageUrl: string): AudioProcess {
); );
let stderr = ""; let stderr = "";
let killed = false;
child.stderr.setEncoding("utf8"); child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => { child.stderr.on("data", (chunk: string) => {
stderr = (stderr + chunk).slice(-2000); stderr = (stderr + chunk).slice(-2000);
}); });
child.on("close", (code) => {
if (code !== 0 && code !== null && stderr.trim()) { const failure = new Promise<string | null>((resolve) => {
child.on("close", (code) => {
if (killed || code === 0 || code === null) {
resolve(null);
return;
}
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error"); log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
} resolve(firstUsefulError(stderr));
});
child.on("error", (err: Error) => resolve(err.message));
}); });
return { return {
stream: child.stdout, stream: child.stdout,
failure,
kill: () => { kill: () => {
killed = true;
if (child.exitCode === null) child.kill("SIGKILL"); if (child.exitCode === null) child.kill("SIGKILL");
}, },
}; };