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:
co-authored by
Claude Opus 5
parent
1ac99b4a10
commit
2b999bf2a0
+8
-1
@@ -27,9 +27,16 @@ SESSION_TTL_HOURS=168
|
|||||||
# Путь к yt-dlp. В docker-образе он уже установлен.
|
# Путь к yt-dlp. В docker-образе он уже установлен.
|
||||||
YTDLP_PATH=yt-dlp
|
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
|
# YTDLP_COOKIES=/data/cookies.txt
|
||||||
|
|
||||||
|
# Необязательно: дополнительные --extractor-args, через ";".
|
||||||
|
# Помогает, когда YouTube не отдаёт форматы серверному IP:
|
||||||
|
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
|
||||||
|
|
||||||
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
|
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
|
||||||
# LOCAL_MEDIA_DIR=/media/music
|
# LOCAL_MEDIA_DIR=/media/music
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,35 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||||||
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||||||
тогда заработают `local:` и поиск по медиатеке.
|
тогда заработают `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`.
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const schema = z.object({
|
|||||||
|
|
||||||
YTDLP_PATH: z.string().default("yt-dlp"),
|
YTDLP_PATH: z.string().default("yt-dlp"),
|
||||||
YTDLP_COOKIES: z.string().optional(),
|
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(),
|
LOCAL_MEDIA_DIR: z.string().optional(),
|
||||||
|
|
||||||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,7 @@ import { startBot } from "./bot/index.js";
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { MusicManager } from "./core/manager.js";
|
import { MusicManager } from "./core/manager.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { checkYtDlp } from "./sources/index.js";
|
import { checkCookies, checkYtDlp } from "./sources/index.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const ytdlpVersion = await checkYtDlp();
|
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 manager = new MusicManager();
|
||||||
const bot = await startBot(manager);
|
const bot = await startBot(manager);
|
||||||
const app = await startApiServer({ manager, context: bot.context });
|
const app = await startApiServer({ manager, context: bot.context });
|
||||||
|
|||||||
+143
-143
@@ -1,143 +1,143 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js";
|
import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js";
|
||||||
import * as direct from "./direct.js";
|
import * as direct from "./direct.js";
|
||||||
import * as local from "./local.js";
|
import * as local from "./local.js";
|
||||||
import * as ytdlp from "./ytdlp.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";
|
export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js";
|
||||||
|
|
||||||
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
|
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
|
||||||
const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"];
|
const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"];
|
||||||
|
|
||||||
function asUrl(value: string): URL | null {
|
function asUrl(value: string): URL | null {
|
||||||
if (!/^https?:\/\//i.test(value)) return null;
|
if (!/^https?:\/\//i.test(value)) return null;
|
||||||
try {
|
try {
|
||||||
return new URL(value);
|
return new URL(value);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostMatches(url: URL, hosts: string[]): boolean {
|
function hostMatches(url: URL, hosts: string[]): boolean {
|
||||||
const host = url.hostname.replace(/^www\./, "");
|
const host = url.hostname.replace(/^www\./, "");
|
||||||
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
|
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedQuery {
|
interface ParsedQuery {
|
||||||
text: string;
|
text: string;
|
||||||
forced: "youtube" | "soundcloud" | "local" | null;
|
forced: "youtube" | "soundcloud" | "local" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePrefix(raw: string): ParsedQuery {
|
function parsePrefix(raw: string): ParsedQuery {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed);
|
const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed);
|
||||||
if (!match) return { text: trimmed, forced: null };
|
if (!match) return { text: trimmed, forced: null };
|
||||||
const [, prefix, rest] = match as unknown as [string, string, string];
|
const [, prefix, rest] = match as unknown as [string, string, string];
|
||||||
const key = prefix.toLowerCase();
|
const key = prefix.toLowerCase();
|
||||||
if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" };
|
if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" };
|
||||||
if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" };
|
if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" };
|
||||||
return { text: rest.trim(), forced: "youtube" };
|
return { text: rest.trim(), forced: "youtube" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Turns whatever a user typed into a playable set of tracks. */
|
/** Turns whatever a user typed into a playable set of tracks. */
|
||||||
export async function resolveQuery(
|
export async function resolveQuery(
|
||||||
rawQuery: string,
|
rawQuery: string,
|
||||||
requestedBy: Requester,
|
requestedBy: Requester,
|
||||||
maxTracks = config.MAX_QUEUE_SIZE,
|
maxTracks = config.MAX_QUEUE_SIZE,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const { text, forced } = parsePrefix(rawQuery);
|
const { text, forced } = parsePrefix(rawQuery);
|
||||||
if (!text) throw new UserFacingError("Укажите название трека или ссылку");
|
if (!text) throw new UserFacingError("Укажите название трека или ссылку");
|
||||||
|
|
||||||
if (forced === "local") {
|
if (forced === "local") {
|
||||||
const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy);
|
const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy);
|
||||||
if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено");
|
if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено");
|
||||||
return { tracks: tracks.slice(0, 1), playlist: null };
|
return { tracks: tracks.slice(0, 1), playlist: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = asUrl(text);
|
const url = asUrl(text);
|
||||||
if (url) {
|
if (url) {
|
||||||
if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) {
|
if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) {
|
||||||
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||||
}
|
}
|
||||||
const probed = await direct.probe(text);
|
const probed = await direct.probe(text);
|
||||||
if (probed.isMedia) {
|
if (probed.isMedia) {
|
||||||
return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null };
|
return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null };
|
||||||
}
|
}
|
||||||
// Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...).
|
// Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...).
|
||||||
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isEnabled() && forced === null) {
|
if (local.isEnabled() && forced === null) {
|
||||||
const localHits = await local.search(text, 1, requestedBy);
|
const localHits = await local.search(text, 1, requestedBy);
|
||||||
if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null };
|
if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy);
|
const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy);
|
||||||
if (tracks.length === 0) throw new UserFacingError("Ничего не найдено");
|
if (tracks.length === 0) throw new UserFacingError("Ничего не найдено");
|
||||||
return { tracks, playlist: null };
|
return { tracks, playlist: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Multi-result search used by the `search` command and the web panel. */
|
/** Multi-result search used by the `search` command and the web panel. */
|
||||||
export async function searchTracks(
|
export async function searchTracks(
|
||||||
rawQuery: string,
|
rawQuery: string,
|
||||||
requestedBy: Requester,
|
requestedBy: Requester,
|
||||||
limit = config.SEARCH_RESULT_LIMIT,
|
limit = config.SEARCH_RESULT_LIMIT,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
const { text, forced } = parsePrefix(rawQuery);
|
const { text, forced } = parsePrefix(rawQuery);
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|
||||||
const url = asUrl(text);
|
const url = asUrl(text);
|
||||||
if (url) {
|
if (url) {
|
||||||
const result = await resolveQuery(text, requestedBy);
|
const result = await resolveQuery(text, requestedBy);
|
||||||
return result.tracks;
|
return result.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forced === "local") return local.search(text, limit, requestedBy);
|
if (forced === "local") return local.search(text, limit, requestedBy);
|
||||||
|
|
||||||
const [remote, localHits] = await Promise.all([
|
const [remote, localHits] = await Promise.all([
|
||||||
ytdlp.search(text, forced ?? "youtube", limit, requestedBy),
|
ytdlp.search(text, forced ?? "youtube", limit, requestedBy),
|
||||||
local.isEnabled() && forced === null
|
local.isEnabled() && forced === null
|
||||||
? local.search(text, 3, requestedBy).catch(() => [])
|
? local.search(text, 3, requestedBy).catch(() => [])
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
return [...localHits, ...remote].slice(0, limit);
|
return [...localHits, ...remote].slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackInput {
|
export interface PlaybackInput {
|
||||||
/** Either a file path / URL for ffmpeg, or a piped stream. */
|
/** Either a file path / URL for ffmpeg, or a piped stream. */
|
||||||
input: string | Readable;
|
input: string | Readable;
|
||||||
inputOptions: string[];
|
inputOptions: string[];
|
||||||
cleanup(): void;
|
cleanup(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTP_RESILIENCE = [
|
const HTTP_RESILIENCE = [
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
|
/** 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): Promise<PlaybackInput> {
|
||||||
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
|
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
|
||||||
|
|
||||||
if (track.source === "local") {
|
if (track.source === "local") {
|
||||||
const filePath = await local.assertInsideLibrary(track.url);
|
const filePath = await local.assertInsideLibrary(track.url);
|
||||||
return { input: filePath, inputOptions: seekOptions, cleanup: () => {} };
|
return { input: filePath, inputOptions: seekOptions, cleanup: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.source === "direct") {
|
if (track.source === "direct") {
|
||||||
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seekSeconds > 0) {
|
if (seekSeconds > 0) {
|
||||||
// Seeking over a pipe would mean decoding everything up to the offset, so we
|
// Seeking over a pipe would mean decoding everything up to the offset, so we
|
||||||
// resolve the CDN URL instead and let ffmpeg do an HTTP range request.
|
// resolve the CDN URL instead and let ffmpeg do an HTTP range request.
|
||||||
const streamUrl = await ytdlp.resolveStreamUrl(track.url);
|
const streamUrl = await ytdlp.resolveStreamUrl(track.url);
|
||||||
return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
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() };
|
||||||
}
|
}
|
||||||
|
|||||||
+248
-221
@@ -1,221 +1,248 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Readable } from "node:stream";
|
import { constants } from "node:fs";
|
||||||
import { config } from "../config.js";
|
import { access } from "node:fs/promises";
|
||||||
import { logger } from "../logger.js";
|
import type { Readable } from "node:stream";
|
||||||
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
const log = logger.child({ mod: "yt-dlp" });
|
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
|
||||||
|
|
||||||
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
|
const log = logger.child({ mod: "yt-dlp" });
|
||||||
interface YtDlpEntry {
|
|
||||||
id?: string;
|
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
|
||||||
title?: string;
|
interface YtDlpEntry {
|
||||||
duration?: number | null;
|
id?: string;
|
||||||
uploader?: string | null;
|
title?: string;
|
||||||
channel?: string | null;
|
duration?: number | null;
|
||||||
artist?: string | null;
|
uploader?: string | null;
|
||||||
webpage_url?: string | null;
|
channel?: string | null;
|
||||||
url?: string | null;
|
artist?: string | null;
|
||||||
original_url?: string | null;
|
webpage_url?: string | null;
|
||||||
thumbnail?: string | null;
|
url?: string | null;
|
||||||
thumbnails?: Array<{ url?: string }> | null;
|
original_url?: string | null;
|
||||||
is_live?: boolean | null;
|
thumbnail?: string | null;
|
||||||
live_status?: string | null;
|
thumbnails?: Array<{ url?: string }> | null;
|
||||||
extractor_key?: string | null;
|
is_live?: boolean | null;
|
||||||
ie_key?: string | null;
|
live_status?: string | null;
|
||||||
_type?: string;
|
extractor_key?: string | null;
|
||||||
entries?: YtDlpEntry[] | null;
|
ie_key?: string | null;
|
||||||
}
|
_type?: string;
|
||||||
|
entries?: YtDlpEntry[] | null;
|
||||||
function baseArgs(): string[] {
|
}
|
||||||
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
|
|
||||||
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
|
function baseArgs(): string[] {
|
||||||
return args;
|
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(";") ?? []) {
|
||||||
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
const trimmed = value.trim();
|
||||||
return new Promise((resolve, reject) => {
|
if (trimmed) args.push("--extractor-args", trimmed);
|
||||||
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
|
}
|
||||||
let stdout = "";
|
return args;
|
||||||
let stderr = "";
|
}
|
||||||
const timer = setTimeout(() => {
|
|
||||||
child.kill("SIGKILL");
|
export type CookieStatus = "ok" | "read-only" | "missing";
|
||||||
reject(new UserFacingError("yt-dlp не ответил вовремя"));
|
|
||||||
}, timeoutMs);
|
/**
|
||||||
|
* yt-dlp rewrites the cookie file after every run to persist rotated cookies,
|
||||||
child.stdout.setEncoding("utf8");
|
* so a read-only file quietly degrades back to anonymous access.
|
||||||
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
*/
|
||||||
child.stderr.setEncoding("utf8");
|
export async function checkCookies(): Promise<CookieStatus | null> {
|
||||||
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
if (!config.YTDLP_COOKIES) return null;
|
||||||
|
try {
|
||||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
await access(config.YTDLP_COOKIES, constants.R_OK | constants.W_OK);
|
||||||
clearTimeout(timer);
|
return "ok";
|
||||||
if (err.code === "ENOENT") {
|
} catch {
|
||||||
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
|
try {
|
||||||
return;
|
await access(config.YTDLP_COOKIES, constants.R_OK);
|
||||||
}
|
return "read-only";
|
||||||
reject(err);
|
} catch {
|
||||||
});
|
return "missing";
|
||||||
|
}
|
||||||
child.on("close", (code) => {
|
}
|
||||||
clearTimeout(timer);
|
}
|
||||||
if (code === 0 || stdout.trim().length > 0) {
|
|
||||||
resolve(stdout);
|
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
||||||
return;
|
return new Promise((resolve, reject) => {
|
||||||
}
|
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
|
||||||
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
let stdout = "";
|
||||||
reject(new UserFacingError(firstUsefulError(stderr)));
|
let stderr = "";
|
||||||
});
|
const timer = setTimeout(() => {
|
||||||
});
|
child.kill("SIGKILL");
|
||||||
}
|
reject(new UserFacingError("yt-dlp не ответил вовремя"));
|
||||||
|
}, timeoutMs);
|
||||||
function firstUsefulError(stderr: string): string {
|
|
||||||
const line = stderr
|
child.stdout.setEncoding("utf8");
|
||||||
.split(/\r?\n/)
|
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
||||||
.map((l) => l.trim())
|
child.stderr.setEncoding("utf8");
|
||||||
.find((l) => l.toUpperCase().startsWith("ERROR"));
|
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
||||||
if (!line) return "Не удалось получить трек";
|
|
||||||
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
}
|
clearTimeout(timer);
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
function sourceOf(entry: YtDlpEntry): SourceKind {
|
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
|
||||||
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
|
return;
|
||||||
if (key.includes("soundcloud")) return "soundcloud";
|
}
|
||||||
if (key.includes("youtube")) return "youtube";
|
reject(err);
|
||||||
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
});
|
||||||
if (url.includes("soundcloud.com")) return "soundcloud";
|
|
||||||
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
|
child.on("close", (code) => {
|
||||||
return "direct";
|
clearTimeout(timer);
|
||||||
}
|
if (code === 0 || stdout.trim().length > 0) {
|
||||||
|
resolve(stdout);
|
||||||
function pickThumbnail(entry: YtDlpEntry): string | null {
|
return;
|
||||||
if (entry.thumbnail) return entry.thumbnail;
|
}
|
||||||
const list = entry.thumbnails ?? [];
|
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
||||||
const last = list.at(-1);
|
reject(new UserFacingError(firstUsefulError(stderr)));
|
||||||
return last?.url ?? null;
|
});
|
||||||
}
|
});
|
||||||
|
}
|
||||||
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
|
|
||||||
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
|
function firstUsefulError(stderr: string): string {
|
||||||
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
const line = stderr
|
||||||
return {
|
.split(/\r?\n/)
|
||||||
id: randomUUID(),
|
.map((l) => l.trim())
|
||||||
title: entry.title?.trim() || "Без названия",
|
.find((l) => l.toUpperCase().startsWith("ERROR"));
|
||||||
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
|
if (!line) return "Не удалось получить трек";
|
||||||
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
|
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
|
||||||
isLive,
|
}
|
||||||
url,
|
|
||||||
thumbnail: pickThumbnail(entry),
|
function sourceOf(entry: YtDlpEntry): SourceKind {
|
||||||
source: fallbackSource ?? sourceOf(entry),
|
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
|
||||||
requestedBy,
|
if (key.includes("soundcloud")) return "soundcloud";
|
||||||
};
|
if (key.includes("youtube")) return "youtube";
|
||||||
}
|
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
||||||
|
if (url.includes("soundcloud.com")) return "soundcloud";
|
||||||
function parseNdjson(stdout: string): YtDlpEntry[] {
|
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
|
||||||
const entries: YtDlpEntry[] = [];
|
return "direct";
|
||||||
for (const line of stdout.split(/\r?\n/)) {
|
}
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith("{")) continue;
|
function pickThumbnail(entry: YtDlpEntry): string | null {
|
||||||
try {
|
if (entry.thumbnail) return entry.thumbnail;
|
||||||
entries.push(JSON.parse(trimmed) as YtDlpEntry);
|
const list = entry.thumbnails ?? [];
|
||||||
} catch {
|
const last = list.at(-1);
|
||||||
// yt-dlp occasionally interleaves non-JSON noise; skip it.
|
return last?.url ?? null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return entries;
|
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
|
||||||
}
|
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
|
||||||
|
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
||||||
export async function search(
|
return {
|
||||||
query: string,
|
id: randomUUID(),
|
||||||
kind: "youtube" | "soundcloud",
|
title: entry.title?.trim() || "Без названия",
|
||||||
limit: number,
|
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
|
||||||
requestedBy: Requester,
|
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
|
||||||
): Promise<Track[]> {
|
isLive,
|
||||||
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
url,
|
||||||
const stdout = await runYtDlp([
|
thumbnail: pickThumbnail(entry),
|
||||||
...baseArgs(),
|
source: fallbackSource ?? sourceOf(entry),
|
||||||
"--flat-playlist",
|
requestedBy,
|
||||||
"--dump-json",
|
};
|
||||||
`${prefix}${limit}:${query}`,
|
}
|
||||||
]);
|
|
||||||
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
|
function parseNdjson(stdout: string): YtDlpEntry[] {
|
||||||
}
|
const entries: YtDlpEntry[] = [];
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
const trimmed = line.trim();
|
||||||
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
|
if (!trimmed.startsWith("{")) continue;
|
||||||
const stdout = await runYtDlp([
|
try {
|
||||||
...baseArgs(),
|
entries.push(JSON.parse(trimmed) as YtDlpEntry);
|
||||||
"--flat-playlist",
|
} catch {
|
||||||
"--dump-single-json",
|
// yt-dlp occasionally interleaves non-JSON noise; skip it.
|
||||||
"--playlist-end",
|
}
|
||||||
String(maxTracks),
|
}
|
||||||
url,
|
return entries;
|
||||||
]);
|
}
|
||||||
const root = parseNdjson(stdout)[0];
|
|
||||||
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
|
export async function search(
|
||||||
|
query: string,
|
||||||
if (root._type === "playlist" && Array.isArray(root.entries)) {
|
kind: "youtube" | "soundcloud",
|
||||||
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
|
limit: number,
|
||||||
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
|
requestedBy: Requester,
|
||||||
return {
|
): Promise<Track[]> {
|
||||||
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
|
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
||||||
playlist: {
|
const stdout = await runYtDlp([
|
||||||
title: root.title?.trim() || "Плейлист",
|
...baseArgs(),
|
||||||
url: root.webpage_url ?? url,
|
"--flat-playlist",
|
||||||
trackCount: entries.length,
|
"--dump-json",
|
||||||
},
|
`${prefix}${limit}:${query}`,
|
||||||
};
|
]);
|
||||||
}
|
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
|
||||||
|
}
|
||||||
return { tracks: [toTrack(root, requestedBy)], playlist: null };
|
|
||||||
}
|
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
||||||
|
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
|
||||||
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
const stdout = await runYtDlp([
|
||||||
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
...baseArgs(),
|
||||||
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
|
"--flat-playlist",
|
||||||
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
"--dump-single-json",
|
||||||
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
"--playlist-end",
|
||||||
return url;
|
String(maxTracks),
|
||||||
}
|
url,
|
||||||
|
]);
|
||||||
export interface AudioProcess {
|
const root = parseNdjson(stdout)[0];
|
||||||
stream: Readable;
|
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
|
||||||
kill(): void;
|
|
||||||
}
|
if (root._type === "playlist" && Array.isArray(root.entries)) {
|
||||||
|
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
|
||||||
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
|
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
|
||||||
export function openAudioStream(pageUrl: string): AudioProcess {
|
return {
|
||||||
const child = spawn(
|
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
|
||||||
config.YTDLP_PATH,
|
playlist: {
|
||||||
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
|
title: root.title?.trim() || "Плейлист",
|
||||||
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
url: root.webpage_url ?? url,
|
||||||
);
|
trackCount: entries.length,
|
||||||
|
},
|
||||||
let stderr = "";
|
};
|
||||||
child.stderr.setEncoding("utf8");
|
}
|
||||||
child.stderr.on("data", (chunk: string) => {
|
|
||||||
stderr = (stderr + chunk).slice(-2000);
|
return { tracks: [toTrack(root, requestedBy)], playlist: null };
|
||||||
});
|
}
|
||||||
child.on("close", (code) => {
|
|
||||||
if (code !== 0 && code !== null && stderr.trim()) {
|
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
||||||
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
||||||
}
|
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
|
||||||
});
|
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
||||||
|
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
||||||
return {
|
return url;
|
||||||
stream: child.stdout,
|
}
|
||||||
kill: () => {
|
|
||||||
if (child.exitCode === null) child.kill("SIGKILL");
|
export interface AudioProcess {
|
||||||
},
|
stream: Readable;
|
||||||
};
|
kill(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAvailable(): Promise<string | null> {
|
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
|
||||||
try {
|
export function openAudioStream(pageUrl: string): AudioProcess {
|
||||||
const out = await runYtDlp(["--version"], 15_000);
|
const child = spawn(
|
||||||
return out.trim() || null;
|
config.YTDLP_PATH,
|
||||||
} catch {
|
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
|
||||||
return null;
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stderr.on("data", (chunk: string) => {
|
||||||
|
stderr = (stderr + chunk).slice(-2000);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0 && code !== null && stderr.trim()) {
|
||||||
|
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: child.stdout,
|
||||||
|
kill: () => {
|
||||||
|
if (child.exitCode === null) child.kill("SIGKILL");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAvailable(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const out = await runYtDlp(["--version"], 15_000);
|
||||||
|
return out.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user