From 6d65134f8ecb4cafcf1b54024436df72286b1f5d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 00:20:35 +0300 Subject: [PATCH] Add only the track when a link carries both a video and a list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link copied from an open mix (watch?v=…&list=RD…) expanded into the whole radio station — 500 entries from one paste. Any URL with a `v=` parameter now resolves to that single track, and only a /playlist?list=… URL expands. The rule is deliberately blunt rather than keyed on start_radio, so pasting a link behaves the same way every time. Playlist expansion is also capped separately from the queue limit (MAX_PLAYLIST_TRACKS, 100 by default) and the chat says when a playlist hit that ceiling. Verified against the real yt-dlp: both mix links resolve to one track, a plain video link to one, and a playlist URL to its 13 entries. Co-Authored-By: Claude Opus 5 --- .env.example | 3 +++ README.md | 4 ++++ src/bot/commands.ts | 4 +++- src/config.ts | 2 ++ src/sources/index.ts | 17 +++++++++++++++-- src/sources/ytdlp.ts | 10 +++++++--- 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 3e7fe40..45f50c4 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,9 @@ YTDLP_PATH=yt-dlp DEFAULT_VOLUME=60 MAX_QUEUE_SIZE=500 + +# Сколько треков максимум добавит одна ссылка на плейлист или микс. +MAX_PLAYLIST_TRACKS=100 SEARCH_RESULT_LIMIT=10 # Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой diff --git a/README.md b/README.md index f269930..01c8664 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,10 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f Поставьте `false`, если хотите выбирать канал вручную. - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. +- `MAX_PLAYLIST_TRACKS` — сколько треков максимум добавит одна ссылка на плейлист или микс + (по умолчанию 100). Любая ссылка с `v=` — в том числе скопированная из открытого микса + `watch?v=…&list=RD…` — добавляет **только сам трек**: обычно имеют в виду именно его. + Чтобы добавить весь список, вставьте ссылку вида `/playlist?list=…`. - `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти не считается: пока в канале кто-то есть, бот ждёт следующий трек. diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 4d70389..8a75be2 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -53,8 +53,10 @@ async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now") }); if (outcome.playlist) { + const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS; await ctx.reply( - `📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`, + `📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url})` + + (capped ? ` — это предел на один плейлист (MAX_PLAYLIST_TRACKS).` : "."), ); return; } diff --git a/src/config.ts b/src/config.ts index 72ad146..006523a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,8 @@ const schema = z.object({ DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60), MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500), + /** Upper bound on how many tracks one pasted playlist may add. */ + MAX_PLAYLIST_TRACKS: z.coerce.number().int().positive().default(100), SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10), /** Seconds to wait after the last human leaves the voice channel (0 — never leave). */ EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120), diff --git a/src/sources/index.ts b/src/sources/index.ts index 112e41c..33b2d96 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -25,6 +25,17 @@ function hostMatches(url: URL, hosts: string[]): boolean { return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)); } +/** + * A YouTube link copied while a mix or playlist is open carries both `v` and + * `list`. People mean the track they were listening to — expanding the list + * would dump a whole radio station into the queue. The rule stays deliberately + * blunt (any `v=` means one track) so pasting a link is predictable; a + * `/playlist?list=…` URL is how you ask for the whole list. + */ +function isTrackInsidePlaylist(url: URL): boolean { + return Boolean(url.searchParams.get("v") && url.searchParams.get("list")); +} + interface ParsedQuery { text: string; forced: "youtube" | "soundcloud" | "local" | null; @@ -58,15 +69,17 @@ export async function resolveQuery( const url = asUrl(text); if (url) { + const limit = Math.min(maxTracks, config.MAX_PLAYLIST_TRACKS); + const singleTrack = isTrackInsidePlaylist(url); if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) { - return ytdlp.resolveUrl(text, requestedBy, maxTracks); + return ytdlp.resolveUrl(text, requestedBy, limit, { singleTrack }); } 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); + return ytdlp.resolveUrl(text, requestedBy, limit, { singleTrack }); } if (local.isEnabled() && forced === null) { diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 64a1c63..d0df686 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -212,13 +212,17 @@ export async function search( } /** 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 { +export async function resolveUrl( + url: string, + requestedBy: Requester, + maxTracks: number, + options: { singleTrack?: boolean } = {}, +): Promise { const { stdout, stderr } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--dump-single-json", - "--playlist-end", - String(maxTracks), + ...(options.singleTrack ? ["--no-playlist"] : ["--playlist-end", String(maxTracks)]), url, ]); const root = parseNdjson(stdout)[0];