From 971fd65b1f41b9cf8f7ca9dd14eb5bde32c41ed2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 00:11:29 +0300 Subject: [PATCH] Add a source picker to search and use the full window width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 ++ src/api/server.ts | 14 ++++++++++-- src/core/manager.ts | 11 +++++++--- src/sources/index.ts | 34 ++++++++++++++++++++++++------ src/sources/ytdlp.ts | 12 +++++++++-- web/src/api.ts | 6 ++++-- web/src/components/SearchPanel.tsx | 19 ++++++++++++++++- web/src/styles.css | 19 +++++++++++++---- 8 files changed, 96 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f0bc988..f269930 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f | `!help` | список команд | Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека. +В панели то же самое выбирается списком слева от строки поиска; по умолчанию ищет везде сразу, +а префикс в запросе перебивает выбор в списке. В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками, клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история. diff --git a/src/api/server.ts b/src/api/server.ts index 0c914cc..f4d5c84 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -210,8 +210,18 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { const { id } = serverParams.parse(request.params); const session = await requireServerAccess(request, reply, id); if (!session) return reply; - const { q } = z.object({ q: z.string().min(1) }).parse(request.query); - const tracks = await manager.search(q, { id: session.userId, username: session.username }); + const { q, source } = z + .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); return reply.send({ tracks }); }); diff --git a/src/core/manager.ts b/src/core/manager.ts index 7c3656a..2d0d02f 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { config } from "../config.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 { UserFacingError, type LoopMode, @@ -223,8 +223,13 @@ export class MusicManager extends EventEmitter { }; } - search(query: string, requester: Requester, limit?: number): Promise { - return searchTracks(query, requester, limit); + search( + query: string, + requester: Requester, + limit?: number, + source: SearchSource = "all", + ): Promise { + return searchTracks(query, requester, limit, source); } private async require(serverId: string, userId: string): Promise { diff --git a/src/sources/index.ts b/src/sources/index.ts index 429ea8b..112e41c 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -79,11 +79,26 @@ export async function resolveQuery( 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. */ export async function searchTracks( rawQuery: string, requestedBy: Requester, limit = config.SEARCH_RESULT_LIMIT, + source: SearchSource = "all", ): Promise { const { text, forced } = parsePrefix(rawQuery); if (!text) return []; @@ -94,15 +109,20 @@ export async function searchTracks( 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([ - searchWithFallback(text, forced, limit, requestedBy), - local.isEnabled() && forced === null - ? local.search(text, 3, requestedBy).catch(() => []) - : Promise.resolve([]), + if (target === "local") return local.search(text, limit, requestedBy); + if (target === "youtube" || target === "soundcloud") { + return ytdlp.search(text, target, limit, requestedBy); + } + + 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); } /** diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 4e0c4ff..213a63c 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -185,7 +185,7 @@ 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 { - const { stdout } = await runYtDlp([ + const { stdout, stderr } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--dump-single-json", @@ -194,7 +194,15 @@ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: url, ]); 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)) { const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e)); diff --git a/web/src/api.ts b/web/src/api.ts index c79823e..b9a265a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -42,8 +42,10 @@ export const api = { state: (serverId: string) => request(`/api/servers/${serverId}/state`), - search: (serverId: string, query: string) => - request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), + search: (serverId: string, query: string, source: string) => + request<{ tracks: Track[] }>( + `/api/servers/${serverId}/search?q=${encodeURIComponent(query)}&source=${source}`, + ), play: ( serverId: string, diff --git a/web/src/components/SearchPanel.tsx b/web/src/components/SearchPanel.tsx index 5e7eda7..789fe9d 100644 --- a/web/src/components/SearchPanel.tsx +++ b/web/src/components/SearchPanel.tsx @@ -16,8 +16,11 @@ interface Props { onError(message: string | null): void; } +const SOURCE_KEY = "mbot.searchSource"; + export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { const [query, setQuery] = useState(""); + const [source, setSource] = useState(() => localStorage.getItem(SOURCE_KEY) ?? "all"); const [results, setResults] = useState([]); const [busy, setBusy] = useState(false); const [lastAdded, setLastAdded] = useState(null); @@ -38,7 +41,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro setQuery(""); return; } - const { tracks } = await api.search(serverId, value); + const { tracks } = await api.search(serverId, value, source); setResults(tracks); setSearched(true); } catch (err) { @@ -62,6 +65,20 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro

Поиск

+ setQuery(event.target.value)} diff --git a/web/src/styles.css b/web/src/styles.css index fdb5098..22df3d8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -105,9 +105,9 @@ select:focus { } .app { - max-width: 1180px; + max-width: 1680px; margin: 0 auto; - padding: 20px 18px 60px; + padding: 20px 24px 60px; } .topbar { @@ -151,11 +151,17 @@ select:focus { .layout { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; align-items: stretch; } +@media (min-width: 1500px) { + .layout { + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + } +} + .layout > div { display: flex; flex-direction: column; @@ -180,7 +186,7 @@ select:focus { .card.grow { display: flex; flex-direction: column; - min-height: 460px; + min-height: 360px; } .card.grow .track-list { @@ -431,6 +437,11 @@ select:focus { margin-bottom: 12px; } +.search-form select { + width: auto; + flex: 0 0 auto; +} + .hint { color: var(--muted); font-size: 12.5px;