Add only the track when a link carries both a video and a list

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 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 00:20:35 +03:00
co-authored by Claude Opus 5
parent ffcf88efb7
commit 6d65134f8e
6 changed files with 34 additions and 6 deletions
+3
View File
@@ -49,6 +49,9 @@ YTDLP_PATH=yt-dlp
DEFAULT_VOLUME=60
MAX_QUEUE_SIZE=500
# Сколько треков максимум добавит одна ссылка на плейлист или микс.
MAX_PLAYLIST_TRACKS=100
SEARCH_RESULT_LIMIT=10
# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
+4
View File
@@ -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` — не выходить никогда). Пустая очередь поводом уйти
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
+3 -1
View File
@@ -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;
}
+2
View File
@@ -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),
+15 -2
View File
@@ -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) {
+7 -3
View File
@@ -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<SearchResult> {
export async function resolveUrl(
url: string,
requestedBy: Requester,
maxTracks: number,
options: { singleTrack?: boolean } = {},
): Promise<SearchResult> {
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];