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
+27 -7
View File
@@ -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
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. */
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));