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:
co-authored by
Claude Opus 5
parent
ffcf88efb7
commit
6d65134f8e
@@ -49,6 +49,9 @@ YTDLP_PATH=yt-dlp
|
|||||||
|
|
||||||
DEFAULT_VOLUME=60
|
DEFAULT_VOLUME=60
|
||||||
MAX_QUEUE_SIZE=500
|
MAX_QUEUE_SIZE=500
|
||||||
|
|
||||||
|
# Сколько треков максимум добавит одна ссылка на плейлист или микс.
|
||||||
|
MAX_PLAYLIST_TRACKS=100
|
||||||
SEARCH_RESULT_LIMIT=10
|
SEARCH_RESULT_LIMIT=10
|
||||||
|
|
||||||
# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
|
# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
Поставьте `false`, если хотите выбирать канал вручную.
|
Поставьте `false`, если хотите выбирать канал вручную.
|
||||||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||||||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||||||
|
- `MAX_PLAYLIST_TRACKS` — сколько треков максимум добавит одна ссылка на плейлист или микс
|
||||||
|
(по умолчанию 100). Любая ссылка с `v=` — в том числе скопированная из открытого микса
|
||||||
|
`watch?v=…&list=RD…` — добавляет **только сам трек**: обычно имеют в виду именно его.
|
||||||
|
Чтобы добавить весь список, вставьте ссылку вида `/playlist?list=…`.
|
||||||
- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает
|
- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает
|
||||||
голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти
|
голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти
|
||||||
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
|
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
|
||||||
|
|||||||
+3
-1
@@ -53,8 +53,10 @@ async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now")
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (outcome.playlist) {
|
if (outcome.playlist) {
|
||||||
|
const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS;
|
||||||
await ctx.reply(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const schema = z.object({
|
|||||||
|
|
||||||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||||
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
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),
|
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). */
|
/** 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),
|
EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120),
|
||||||
|
|||||||
+15
-2
@@ -25,6 +25,17 @@ function hostMatches(url: URL, hosts: string[]): boolean {
|
|||||||
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
|
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 {
|
interface ParsedQuery {
|
||||||
text: string;
|
text: string;
|
||||||
forced: "youtube" | "soundcloud" | "local" | null;
|
forced: "youtube" | "soundcloud" | "local" | null;
|
||||||
@@ -58,15 +69,17 @@ export async function resolveQuery(
|
|||||||
|
|
||||||
const url = asUrl(text);
|
const url = asUrl(text);
|
||||||
if (url) {
|
if (url) {
|
||||||
|
const limit = Math.min(maxTracks, config.MAX_PLAYLIST_TRACKS);
|
||||||
|
const singleTrack = isTrackInsidePlaylist(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, limit, { singleTrack });
|
||||||
}
|
}
|
||||||
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, limit, { singleTrack });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.isEnabled() && forced === null) {
|
if (local.isEnabled() && forced === null) {
|
||||||
|
|||||||
@@ -212,13 +212,17 @@ export async function search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
/** 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([
|
const { stdout, stderr } = await runYtDlp([
|
||||||
...baseArgs(),
|
...baseArgs(),
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--dump-single-json",
|
"--dump-single-json",
|
||||||
"--playlist-end",
|
...(options.singleTrack ? ["--no-playlist"] : ["--playlist-end", String(maxTracks)]),
|
||||||
String(maxTracks),
|
|
||||||
url,
|
url,
|
||||||
]);
|
]);
|
||||||
const root = parseNdjson(stdout)[0];
|
const root = parseNdjson(stdout)[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user