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
+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));
|
||||
|
||||
Reference in New Issue
Block a user