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
|
||||
MAX_QUEUE_SIZE=500
|
||||
|
||||
# Сколько треков максимум добавит одна ссылка на плейлист или микс.
|
||||
MAX_PLAYLIST_TRACKS=100
|
||||
SEARCH_RESULT_LIMIT=10
|
||||
|
||||
# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user