Add a source picker to search and use the full window width

Search now queries YouTube and SoundCloud together by default and
interleaves the two result lists so neither buries the other; a picker
left of the input narrows it to one source (plus the local library when
configured), and a prefix typed into the query still outranks it.

The panel was capped at 1180px, which left most of a wide screen empty —
it now scales to 1680px and gives the search column the extra room.

A dead link also reported "could not parse yt-dlp's response", which
described our parser rather than the problem; it now shows yt-dlp's own
error line, or says the link did not open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 00:11:29 +03:00
co-authored by Claude Opus 5
parent f84489e27e
commit 971fd65b1f
8 changed files with 96 additions and 21 deletions
+2
View File
@@ -134,6 +134,8 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
| `!help` | список команд | | `!help` | список команд |
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека. Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
В панели то же самое выбирается списком слева от строки поиска; по умолчанию ищет везде сразу,
а префикс в запросе перебивает выбор в списке.
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками, В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история. клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
+12 -2
View File
@@ -210,8 +210,18 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
const { id } = serverParams.parse(request.params); const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id); const session = await requireServerAccess(request, reply, id);
if (!session) return reply; if (!session) return reply;
const { q } = z.object({ q: z.string().min(1) }).parse(request.query); const { q, source } = z
const tracks = await manager.search(q, { id: session.userId, username: session.username }); .object({
q: z.string().min(1),
source: z.enum(["all", "youtube", "soundcloud", "local"]).default("all"),
})
.parse(request.query);
const tracks = await manager.search(
q,
{ id: session.userId, username: session.username },
undefined,
source,
);
cacheTracks(tracks); cacheTracks(tracks);
return reply.send({ tracks }); return reply.send({ tracks });
}); });
+8 -3
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { NOTHING_FOUND, resolveQuery, searchTracks } from "../sources/index.js"; import { NOTHING_FOUND, resolveQuery, searchTracks, type SearchSource } from "../sources/index.js";
import { import {
UserFacingError, UserFacingError,
type LoopMode, type LoopMode,
@@ -223,8 +223,13 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
}; };
} }
search(query: string, requester: Requester, limit?: number): Promise<Track[]> { search(
return searchTracks(query, requester, limit); query: string,
requester: Requester,
limit?: number,
source: SearchSource = "all",
): Promise<Track[]> {
return searchTracks(query, requester, limit, source);
} }
private async require(serverId: string, userId: string): Promise<GuildPlayer> { private async require(serverId: string, userId: string): Promise<GuildPlayer> {
+27 -7
View File
@@ -79,11 +79,26 @@ export async function resolveQuery(
return { tracks: tracks.slice(0, 1), playlist: null }; return { tracks: tracks.slice(0, 1), playlist: null };
} }
export type SearchSource = "all" | "youtube" | "soundcloud" | "local";
/** Round-robins two result lists so neither source buries the other. */
function interleave(a: Track[], b: Track[]): Track[] {
const merged: Track[] = [];
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
const first = a[i];
const second = b[i];
if (first) merged.push(first);
if (second) merged.push(second);
}
return merged;
}
/** Multi-result search used by the `search` command and the web panel. */ /** Multi-result search used by the `search` command and the web panel. */
export async function searchTracks( export async function searchTracks(
rawQuery: string, rawQuery: string,
requestedBy: Requester, requestedBy: Requester,
limit = config.SEARCH_RESULT_LIMIT, limit = config.SEARCH_RESULT_LIMIT,
source: SearchSource = "all",
): Promise<Track[]> { ): Promise<Track[]> {
const { text, forced } = parsePrefix(rawQuery); const { text, forced } = parsePrefix(rawQuery);
if (!text) return []; if (!text) return [];
@@ -94,15 +109,20 @@ export async function searchTracks(
return result.tracks; return result.tracks;
} }
if (forced === "local") return local.search(text, limit, requestedBy); // A prefix inside the query is an explicit instruction and outranks the picker.
const target: SearchSource = forced ?? source;
const [remote, localHits] = await Promise.all([ if (target === "local") return local.search(text, limit, requestedBy);
searchWithFallback(text, forced, limit, requestedBy), if (target === "youtube" || target === "soundcloud") {
local.isEnabled() && forced === null return ytdlp.search(text, target, limit, requestedBy);
? local.search(text, 3, requestedBy).catch(() => []) }
: Promise.resolve([]),
const [youtube, soundcloud, localHits] = await Promise.all([
ytdlp.search(text, "youtube", limit, requestedBy).catch(() => []),
ytdlp.search(text, "soundcloud", limit, requestedBy).catch(() => []),
local.isEnabled() ? local.search(text, 3, requestedBy).catch(() => []) : Promise.resolve([]),
]); ]);
return [...localHits, ...remote].slice(0, limit); return [...localHits, ...interleave(youtube, soundcloud)].slice(0, limit);
} }
/** /**
+10 -2
View File
@@ -185,7 +185,7 @@ 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): Promise<SearchResult> {
const { stdout } = await runYtDlp([ const { stdout, stderr } = await runYtDlp([
...baseArgs(), ...baseArgs(),
"--flat-playlist", "--flat-playlist",
"--dump-single-json", "--dump-single-json",
@@ -194,7 +194,15 @@ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks:
url, url,
]); ]);
const root = parseNdjson(stdout)[0]; const root = parseNdjson(stdout)[0];
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp"); if (!root) {
// Typically a dead or malformed link; yt-dlp's own line says it best.
log.warn({ url, stderr: stderr.slice(0, 500) }, "url resolved to nothing");
throw new UserFacingError(
stderr.trim()
? firstUsefulError(stderr)
: "По этой ссылке ничего не открылось — проверьте, что она рабочая",
);
}
if (root._type === "playlist" && Array.isArray(root.entries)) { if (root._type === "playlist" && Array.isArray(root.entries)) {
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e)); const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
+4 -2
View File
@@ -42,8 +42,10 @@ export const api = {
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`), state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
search: (serverId: string, query: string) => search: (serverId: string, query: string, source: string) =>
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), request<{ tracks: Track[] }>(
`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}&source=${source}`,
),
play: ( play: (
serverId: string, serverId: string,
+18 -1
View File
@@ -16,8 +16,11 @@ interface Props {
onError(message: string | null): void; onError(message: string | null): void;
} }
const SOURCE_KEY = "mbot.searchSource";
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [source, setSource] = useState<string>(() => localStorage.getItem(SOURCE_KEY) ?? "all");
const [results, setResults] = useState<Track[]>([]); const [results, setResults] = useState<Track[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [lastAdded, setLastAdded] = useState<string | null>(null); const [lastAdded, setLastAdded] = useState<string | null>(null);
@@ -38,7 +41,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
setQuery(""); setQuery("");
return; return;
} }
const { tracks } = await api.search(serverId, value); const { tracks } = await api.search(serverId, value, source);
setResults(tracks); setResults(tracks);
setSearched(true); setSearched(true);
} catch (err) { } catch (err) {
@@ -62,6 +65,20 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
<div className="card grow"> <div className="card grow">
<h2>Поиск</h2> <h2>Поиск</h2>
<form className="search-form" onSubmit={submit}> <form className="search-form" onSubmit={submit}>
<select
value={source}
onChange={(event) => {
setSource(event.target.value);
localStorage.setItem(SOURCE_KEY, event.target.value);
}}
disabled={!canControl}
title="Где искать"
>
<option value="all">Везде</option>
<option value="youtube">YouTube</option>
<option value="soundcloud">SoundCloud</option>
{localLibrary && <option value="local">Медиатека</option>}
</select>
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
+15 -4
View File
@@ -105,9 +105,9 @@ select:focus {
} }
.app { .app {
max-width: 1180px; max-width: 1680px;
margin: 0 auto; margin: 0 auto;
padding: 20px 18px 60px; padding: 20px 24px 60px;
} }
.topbar { .topbar {
@@ -151,11 +151,17 @@ select:focus {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: 18px; gap: 18px;
align-items: stretch; align-items: stretch;
} }
@media (min-width: 1500px) {
.layout {
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
}
}
.layout > div { .layout > div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -180,7 +186,7 @@ select:focus {
.card.grow { .card.grow {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 460px; min-height: 360px;
} }
.card.grow .track-list { .card.grow .track-list {
@@ -431,6 +437,11 @@ select:focus {
margin-bottom: 12px; margin-bottom: 12px;
} }
.search-form select {
width: auto;
flex: 0 0 auto;
}
.hint { .hint {
color: var(--muted); color: var(--muted);
font-size: 12.5px; font-size: 12.5px;