diff --git a/.env.example b/.env.example index 5ead378..5c474e7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 9a7521a..715fce2 100644 --- a/README.md +++ b/README.md @@ -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`. ## Разработка diff --git a/src/config.ts b/src/config.ts index 41e22ea..b842053 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), diff --git a/src/index.ts b/src/index.ts index b4c923b..2e7d33a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { const ytdlpVersion = await checkYtDlp(); @@ -16,6 +16,18 @@ async function main(): Promise { ); } + 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 }); diff --git a/src/sources/index.ts b/src/sources/index.ts index c065b2a..918083f 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -1,143 +1,143 @@ -import type { Readable } from "node:stream"; -import { config } from "../config.js"; -import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js"; -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 { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js"; - -const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"]; -const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"]; - -function asUrl(value: string): URL | null { - if (!/^https?:\/\//i.test(value)) return null; - try { - return new URL(value); - } catch { - return null; - } -} - -function hostMatches(url: URL, hosts: string[]): boolean { - const host = url.hostname.replace(/^www\./, ""); - return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)); -} - -interface ParsedQuery { - text: string; - forced: "youtube" | "soundcloud" | "local" | null; -} - -function parsePrefix(raw: string): ParsedQuery { - const trimmed = raw.trim(); - const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed); - if (!match) return { text: trimmed, forced: null }; - const [, prefix, rest] = match as unknown as [string, string, string]; - const key = prefix.toLowerCase(); - if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" }; - if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" }; - return { text: rest.trim(), forced: "youtube" }; -} - -/** Turns whatever a user typed into a playable set of tracks. */ -export async function resolveQuery( - rawQuery: string, - requestedBy: Requester, - maxTracks = config.MAX_QUEUE_SIZE, -): Promise { - const { text, forced } = parsePrefix(rawQuery); - if (!text) throw new UserFacingError("Укажите название трека или ссылку"); - - if (forced === "local") { - const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy); - if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено"); - return { tracks: tracks.slice(0, 1), playlist: null }; - } - - const url = asUrl(text); - if (url) { - if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) { - return ytdlp.resolveUrl(text, requestedBy, maxTracks); - } - const probed = await direct.probe(text); - if (probed.isMedia) { - return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null }; - } - // Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...). - return ytdlp.resolveUrl(text, requestedBy, maxTracks); - } - - if (local.isEnabled() && forced === null) { - const localHits = await local.search(text, 1, requestedBy); - if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null }; - } - - const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy); - if (tracks.length === 0) throw new UserFacingError("Ничего не найдено"); - return { tracks, playlist: null }; -} - -/** Multi-result search used by the `search` command and the web panel. */ -export async function searchTracks( - rawQuery: string, - requestedBy: Requester, - limit = config.SEARCH_RESULT_LIMIT, -): Promise { - const { text, forced } = parsePrefix(rawQuery); - if (!text) return []; - - const url = asUrl(text); - if (url) { - const result = await resolveQuery(text, requestedBy); - return result.tracks; - } - - if (forced === "local") return local.search(text, limit, requestedBy); - - const [remote, localHits] = await Promise.all([ - ytdlp.search(text, forced ?? "youtube", limit, requestedBy), - local.isEnabled() && forced === null - ? local.search(text, 3, requestedBy).catch(() => []) - : Promise.resolve([]), - ]); - return [...localHits, ...remote].slice(0, limit); -} - -export interface PlaybackInput { - /** Either a file path / URL for ffmpeg, or a piped stream. */ - input: string | Readable; - inputOptions: string[]; - cleanup(): void; -} - -const HTTP_RESILIENCE = [ - "-reconnect", "1", - "-reconnect_streamed", "1", - "-reconnect_delay_max", "5", -]; - -/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */ -export async function openPlayback(track: Track, seekSeconds = 0): Promise { - const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; - - if (track.source === "local") { - const filePath = await local.assertInsideLibrary(track.url); - return { input: filePath, inputOptions: seekOptions, cleanup: () => {} }; - } - - if (track.source === "direct") { - return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; - } - - if (seekSeconds > 0) { - // 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. - const streamUrl = await ytdlp.resolveStreamUrl(track.url); - return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; - } - - const proc = ytdlp.openAudioStream(track.url); - return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() }; -} +import type { Readable } from "node:stream"; +import { config } from "../config.js"; +import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js"; +import * as direct from "./direct.js"; +import * as local from "./local.js"; +import * as ytdlp 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"]; +const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"]; + +function asUrl(value: string): URL | null { + if (!/^https?:\/\//i.test(value)) return null; + try { + return new URL(value); + } catch { + return null; + } +} + +function hostMatches(url: URL, hosts: string[]): boolean { + const host = url.hostname.replace(/^www\./, ""); + return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)); +} + +interface ParsedQuery { + text: string; + forced: "youtube" | "soundcloud" | "local" | null; +} + +function parsePrefix(raw: string): ParsedQuery { + const trimmed = raw.trim(); + const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed); + if (!match) return { text: trimmed, forced: null }; + const [, prefix, rest] = match as unknown as [string, string, string]; + const key = prefix.toLowerCase(); + if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" }; + if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" }; + return { text: rest.trim(), forced: "youtube" }; +} + +/** Turns whatever a user typed into a playable set of tracks. */ +export async function resolveQuery( + rawQuery: string, + requestedBy: Requester, + maxTracks = config.MAX_QUEUE_SIZE, +): Promise { + const { text, forced } = parsePrefix(rawQuery); + if (!text) throw new UserFacingError("Укажите название трека или ссылку"); + + if (forced === "local") { + const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy); + if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено"); + return { tracks: tracks.slice(0, 1), playlist: null }; + } + + const url = asUrl(text); + if (url) { + if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) { + return ytdlp.resolveUrl(text, requestedBy, maxTracks); + } + const probed = await direct.probe(text); + if (probed.isMedia) { + return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null }; + } + // Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...). + return ytdlp.resolveUrl(text, requestedBy, maxTracks); + } + + if (local.isEnabled() && forced === null) { + const localHits = await local.search(text, 1, requestedBy); + if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null }; + } + + const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy); + if (tracks.length === 0) throw new UserFacingError("Ничего не найдено"); + return { tracks, playlist: null }; +} + +/** Multi-result search used by the `search` command and the web panel. */ +export async function searchTracks( + rawQuery: string, + requestedBy: Requester, + limit = config.SEARCH_RESULT_LIMIT, +): Promise { + const { text, forced } = parsePrefix(rawQuery); + if (!text) return []; + + const url = asUrl(text); + if (url) { + const result = await resolveQuery(text, requestedBy); + return result.tracks; + } + + if (forced === "local") return local.search(text, limit, requestedBy); + + const [remote, localHits] = await Promise.all([ + ytdlp.search(text, forced ?? "youtube", limit, requestedBy), + local.isEnabled() && forced === null + ? local.search(text, 3, requestedBy).catch(() => []) + : Promise.resolve([]), + ]); + return [...localHits, ...remote].slice(0, limit); +} + +export interface PlaybackInput { + /** Either a file path / URL for ffmpeg, or a piped stream. */ + input: string | Readable; + inputOptions: string[]; + cleanup(): void; +} + +const HTTP_RESILIENCE = [ + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", +]; + +/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */ +export async function openPlayback(track: Track, seekSeconds = 0): Promise { + const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; + + if (track.source === "local") { + const filePath = await local.assertInsideLibrary(track.url); + return { input: filePath, inputOptions: seekOptions, cleanup: () => {} }; + } + + if (track.source === "direct") { + return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; + } + + if (seekSeconds > 0) { + // 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. + const streamUrl = await ytdlp.resolveStreamUrl(track.url); + return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; + } + + const proc = ytdlp.openAudioStream(track.url); + return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() }; +} diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 5c4104a..f3d7573 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -1,221 +1,248 @@ -import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import type { Readable } from "node:stream"; -import { config } from "../config.js"; -import { logger } from "../logger.js"; -import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js"; - -const log = logger.child({ mod: "yt-dlp" }); - -/** Raw shape of the fields we consume from yt-dlp's JSON output. */ -interface YtDlpEntry { - id?: string; - title?: string; - duration?: number | null; - uploader?: string | null; - channel?: string | null; - artist?: string | null; - webpage_url?: string | null; - url?: string | null; - original_url?: string | null; - thumbnail?: string | null; - thumbnails?: Array<{ url?: string }> | null; - is_live?: boolean | null; - live_status?: string | null; - extractor_key?: string | 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); - return args; -} - -function runYtDlp(args: string[], timeoutMs = 45_000): Promise { - return new Promise((resolve, reject) => { - const child = spawn(config.YTDLP_PATH, args, { windowsHide: true }); - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - child.kill("SIGKILL"); - reject(new UserFacingError("yt-dlp не ответил вовремя")); - }, timeoutMs); - - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => (stdout += chunk)); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => (stderr += chunk)); - - child.on("error", (err: NodeJS.ErrnoException) => { - clearTimeout(timer); - if (err.code === "ENOENT") { - reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`)); - return; - } - reject(err); - }); - - child.on("close", (code) => { - clearTimeout(timer); - if (code === 0 || stdout.trim().length > 0) { - resolve(stdout); - return; - } - log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed"); - reject(new UserFacingError(firstUsefulError(stderr))); - }); - }); -} - -function firstUsefulError(stderr: string): string { - const line = stderr - .split(/\r?\n/) - .map((l) => l.trim()) - .find((l) => l.toUpperCase().startsWith("ERROR")); - if (!line) return "Не удалось получить трек"; - return line.replace(/^ERROR:\s*/i, "").slice(0, 300); -} - -function sourceOf(entry: YtDlpEntry): SourceKind { - const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase(); - 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"; - if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube"; - return "direct"; -} - -function pickThumbnail(entry: YtDlpEntry): string | null { - if (entry.thumbnail) return entry.thumbnail; - const list = entry.thumbnails ?? []; - const last = list.at(-1); - 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"; - const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? ""; - return { - id: randomUUID(), - title: entry.title?.trim() || "Без названия", - author: entry.artist ?? entry.uploader ?? entry.channel ?? null, - duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)), - isLive, - url, - thumbnail: pickThumbnail(entry), - source: fallbackSource ?? sourceOf(entry), - requestedBy, - }; -} - -function parseNdjson(stdout: string): YtDlpEntry[] { - const entries: YtDlpEntry[] = []; - for (const line of stdout.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("{")) continue; - try { - entries.push(JSON.parse(trimmed) as YtDlpEntry); - } catch { - // yt-dlp occasionally interleaves non-JSON noise; skip it. - } - } - return entries; -} - -export async function search( - query: string, - kind: "youtube" | "soundcloud", - limit: number, - requestedBy: Requester, -): Promise { - const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; - const stdout = await runYtDlp([ - ...baseArgs(), - "--flat-playlist", - "--dump-json", - `${prefix}${limit}:${query}`, - ]); - return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind)); -} - -/** 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 { - const stdout = await runYtDlp([ - ...baseArgs(), - "--flat-playlist", - "--dump-single-json", - "--playlist-end", - String(maxTracks), - url, - ]); - const root = parseNdjson(stdout)[0]; - if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp"); - - if (root._type === "playlist" && Array.isArray(root.entries)) { - const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e)); - if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен"); - return { - tracks: entries.map((entry) => toTrack(entry, requestedBy)), - playlist: { - title: root.title?.trim() || "Плейлист", - url: root.webpage_url ?? url, - trackCount: entries.length, - }, - }; - } - - return { tracks: [toTrack(root, requestedBy)], playlist: null }; -} - -/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */ -export async function resolveStreamUrl(pageUrl: string): Promise { - 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 url; -} - -export interface AudioProcess { - stream: Readable; - kill(): void; -} - -/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */ -export function openAudioStream(pageUrl: string): AudioProcess { - const child = spawn( - config.YTDLP_PATH, - [...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl], - { 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 { - try { - const out = await runYtDlp(["--version"], 15_000); - return out.trim() || null; - } catch { - return null; - } -} +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"; +import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js"; + +const log = logger.child({ mod: "yt-dlp" }); + +/** Raw shape of the fields we consume from yt-dlp's JSON output. */ +interface YtDlpEntry { + id?: string; + title?: string; + duration?: number | null; + uploader?: string | null; + channel?: string | null; + artist?: string | null; + webpage_url?: string | null; + url?: string | null; + original_url?: string | null; + thumbnail?: string | null; + thumbnails?: Array<{ url?: string }> | null; + is_live?: boolean | null; + live_status?: string | null; + extractor_key?: string | 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); + 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 { + 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 { + return new Promise((resolve, reject) => { + const child = spawn(config.YTDLP_PATH, args, { windowsHide: true }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new UserFacingError("yt-dlp не ответил вовремя")); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => (stderr += chunk)); + + child.on("error", (err: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (err.code === "ENOENT") { + reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`)); + return; + } + reject(err); + }); + + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0 || stdout.trim().length > 0) { + resolve(stdout); + return; + } + log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed"); + reject(new UserFacingError(firstUsefulError(stderr))); + }); + }); +} + +function firstUsefulError(stderr: string): string { + const line = stderr + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l.toUpperCase().startsWith("ERROR")); + if (!line) return "Не удалось получить трек"; + return line.replace(/^ERROR:\s*/i, "").slice(0, 300); +} + +function sourceOf(entry: YtDlpEntry): SourceKind { + const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase(); + 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"; + if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube"; + return "direct"; +} + +function pickThumbnail(entry: YtDlpEntry): string | null { + if (entry.thumbnail) return entry.thumbnail; + const list = entry.thumbnails ?? []; + const last = list.at(-1); + 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"; + const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? ""; + return { + id: randomUUID(), + title: entry.title?.trim() || "Без названия", + author: entry.artist ?? entry.uploader ?? entry.channel ?? null, + duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)), + isLive, + url, + thumbnail: pickThumbnail(entry), + source: fallbackSource ?? sourceOf(entry), + requestedBy, + }; +} + +function parseNdjson(stdout: string): YtDlpEntry[] { + const entries: YtDlpEntry[] = []; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + entries.push(JSON.parse(trimmed) as YtDlpEntry); + } catch { + // yt-dlp occasionally interleaves non-JSON noise; skip it. + } + } + return entries; +} + +export async function search( + query: string, + kind: "youtube" | "soundcloud", + limit: number, + requestedBy: Requester, +): Promise { + const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; + const stdout = await runYtDlp([ + ...baseArgs(), + "--flat-playlist", + "--dump-json", + `${prefix}${limit}:${query}`, + ]); + return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind)); +} + +/** 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 { + const stdout = await runYtDlp([ + ...baseArgs(), + "--flat-playlist", + "--dump-single-json", + "--playlist-end", + String(maxTracks), + url, + ]); + const root = parseNdjson(stdout)[0]; + if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp"); + + if (root._type === "playlist" && Array.isArray(root.entries)) { + const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e)); + if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен"); + return { + tracks: entries.map((entry) => toTrack(entry, requestedBy)), + playlist: { + title: root.title?.trim() || "Плейлист", + url: root.webpage_url ?? url, + trackCount: entries.length, + }, + }; + } + + return { tracks: [toTrack(root, requestedBy)], playlist: null }; +} + +/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */ +export async function resolveStreamUrl(pageUrl: string): Promise { + 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 url; +} + +export interface AudioProcess { + stream: Readable; + kill(): void; +} + +/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */ +export function openAudioStream(pageUrl: string): AudioProcess { + const child = spawn( + config.YTDLP_PATH, + [...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl], + { 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 { + try { + const out = await runYtDlp(["--version"], 15_000); + return out.trim() || null; + } catch { + return null; + } +}