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` | список команд |
|
| `!help` | список команд |
|
||||||
|
|
||||||
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
|
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
|
||||||
|
В панели то же самое выбирается списком слева от строки поиска; по умолчанию ищет везде сразу,
|
||||||
|
а префикс в запросе перебивает выбор в списке.
|
||||||
|
|
||||||
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
||||||
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||||||
|
|||||||
+12
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user