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:
co-authored by
Claude Opus 5
parent
f84489e27e
commit
971fd65b1f
@@ -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 перестановка треков стрелками,
|
||||
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||||
|
||||
+12
-2
@@ -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 });
|
||||
});
|
||||
|
||||
+8
-3
@@ -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<ManagerEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
search(query: string, requester: Requester, limit?: number): Promise<Track[]> {
|
||||
return searchTracks(query, requester, limit);
|
||||
search(
|
||||
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> {
|
||||
|
||||
+27
-7
@@ -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<Track[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-2
@@ -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<SearchResult> {
|
||||
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));
|
||||
|
||||
+4
-2
@@ -42,8 +42,10 @@ export const api = {
|
||||
|
||||
state: (serverId: string) => request<ServerStateResponse>(`/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,
|
||||
|
||||
@@ -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<string>(() => localStorage.getItem(SOURCE_KEY) ?? "all");
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastAdded, setLastAdded] = useState<string | null>(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
|
||||
<div className="card grow">
|
||||
<h2>Поиск</h2>
|
||||
<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
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
|
||||
+15
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user