Document YouTube cookie auth and warn when the file is unusable

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>
This commit is contained in:
Leonid Pershin
2026-09-08 23:26:48 +03:00
co-authored by Claude Opus 5
parent 1ac99b4a10
commit 2b999bf2a0
6 changed files with 443 additions and 367 deletions
+8 -1
View File
@@ -27,9 +27,16 @@ SESSION_TTL_HOURS=168
# Путь к yt-dlp. В docker-образе он уже установлен.
YTDLP_PATH=yt-dlp
# Необязательно: файл cookies.txt для приватных/возрастных видео.
# Необязательно: cookies.txt аккаунта YouTube — снимает возрастные ограничения,
# «Sign in to confirm you're not a bot» и открывает приватные/платные видео.
# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки.
# Подробности — в README, раздел «Учётка YouTube (cookies)».
# YTDLP_COOKIES=/data/cookies.txt
# Необязательно: дополнительные --extractor-args, через ";".
# Помогает, когда YouTube не отдаёт форматы серверному IP:
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
# LOCAL_MEDIA_DIR=/media/music
+29 -1
View File
@@ -164,7 +164,35 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
тогда заработают `local:` и поиск по медиатеке.
- `YTDLP_COOKIES` — путь к `cookies.txt`, если YouTube просит подтверждения возраста или логина.
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
## Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
показывать серверным IP.
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
заблокировать, терять основной незачем.
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
сессия быстро протухнет.
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
будет предупреждение с указанием причины.
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
## Разработка
+2
View File
@@ -36,6 +36,8 @@ const schema = z.object({
YTDLP_PATH: z.string().default("yt-dlp"),
YTDLP_COOKIES: z.string().optional(),
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
YTDLP_EXTRACTOR_ARGS: z.string().optional(),
LOCAL_MEDIA_DIR: z.string().optional(),
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
+13 -1
View File
@@ -3,7 +3,7 @@ import { startBot } from "./bot/index.js";
import { config } from "./config.js";
import { MusicManager } from "./core/manager.js";
import { logger } from "./logger.js";
import { checkYtDlp } from "./sources/index.js";
import { checkCookies, checkYtDlp } from "./sources/index.js";
async function main(): Promise<void> {
const ytdlpVersion = await checkYtDlp();
@@ -16,6 +16,18 @@ async function main(): Promise<void> {
);
}
const cookies = await checkCookies();
if (cookies === "ok") {
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
} else if (cookies === "read-only") {
logger.warn(
{ path: config.YTDLP_COOKIES },
"cookie file is not writable — yt-dlp cannot persist rotated cookies and the session will expire early",
);
} else if (cookies === "missing") {
logger.warn({ path: config.YTDLP_COOKIES }, "cookie file from YTDLP_COOKIES does not exist");
}
const manager = new MusicManager();
const bot = await startBot(manager);
const app = await startApiServer({ manager, context: bot.context });
+1 -1
View File
@@ -5,7 +5,7 @@ import * as direct from "./direct.js";
import * as local from "./local.js";
import * as ytdlp from "./ytdlp.js";
export { checkAvailable as checkYtDlp } from "./ytdlp.js";
export { checkAvailable as checkYtDlp, checkCookies } from "./ytdlp.js";
export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js";
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
+27
View File
@@ -1,5 +1,7 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import type { Readable } from "node:stream";
import { config } from "../config.js";
import { logger } from "../logger.js";
@@ -31,9 +33,34 @@ interface YtDlpEntry {
function baseArgs(): string[] {
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
for (const value of config.YTDLP_EXTRACTOR_ARGS?.split(";") ?? []) {
const trimmed = value.trim();
if (trimmed) args.push("--extractor-args", trimmed);
}
return args;
}
export type CookieStatus = "ok" | "read-only" | "missing";
/**
* yt-dlp rewrites the cookie file after every run to persist rotated cookies,
* so a read-only file quietly degrades back to anonymous access.
*/
export async function checkCookies(): Promise<CookieStatus | null> {
if (!config.YTDLP_COOKIES) return null;
try {
await access(config.YTDLP_COOKIES, constants.R_OK | constants.W_OK);
return "ok";
} catch {
try {
await access(config.YTDLP_COOKIES, constants.R_OK);
return "read-only";
} catch {
return "missing";
}
}
}
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });