import type { Readable } from "node:stream"; import { config } from "../config.js"; import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js"; import * as direct from "./direct.js"; import * as local from "./local.js"; import * as ytdlp from "./ytdlp.js"; export { checkAvailable as checkYtDlp, checkCookies } from "./ytdlp.js"; export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js"; const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"]; const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"]; function asUrl(value: string): URL | null { if (!/^https?:\/\//i.test(value)) return null; try { return new URL(value); } catch { return null; } } function hostMatches(url: URL, hosts: string[]): boolean { const host = url.hostname.replace(/^www\./, ""); return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)); } interface ParsedQuery { text: string; forced: "youtube" | "soundcloud" | "local" | null; } function parsePrefix(raw: string): ParsedQuery { const trimmed = raw.trim(); const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed); if (!match) return { text: trimmed, forced: null }; const [, prefix, rest] = match as unknown as [string, string, string]; const key = prefix.toLowerCase(); if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" }; if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" }; return { text: rest.trim(), forced: "youtube" }; } /** Turns whatever a user typed into a playable set of tracks. */ export async function resolveQuery( rawQuery: string, requestedBy: Requester, maxTracks = config.MAX_QUEUE_SIZE, ): Promise { const { text, forced } = parsePrefix(rawQuery); if (!text) throw new UserFacingError("Укажите название трека или ссылку"); if (forced === "local") { const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy); if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено"); return { tracks: tracks.slice(0, 1), playlist: null }; } const url = asUrl(text); if (url) { if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) { return ytdlp.resolveUrl(text, requestedBy, maxTracks); } const probed = await direct.probe(text); if (probed.isMedia) { return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null }; } // Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...). return ytdlp.resolveUrl(text, requestedBy, maxTracks); } if (local.isEnabled() && forced === null) { const localHits = await local.search(text, 1, requestedBy); if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null }; } const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy); if (tracks.length === 0) throw new UserFacingError("Ничего не найдено"); return { tracks, playlist: null }; } /** 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, ): Promise { const { text, forced } = parsePrefix(rawQuery); if (!text) return []; const url = asUrl(text); if (url) { const result = await resolveQuery(text, requestedBy); return result.tracks; } if (forced === "local") return local.search(text, limit, requestedBy); const [remote, localHits] = await Promise.all([ ytdlp.search(text, forced ?? "youtube", limit, requestedBy), local.isEnabled() && forced === null ? local.search(text, 3, requestedBy).catch(() => []) : Promise.resolve([]), ]); return [...localHits, ...remote].slice(0, limit); } export interface PlaybackInput { /** Either a file path / URL for ffmpeg, or a piped stream. */ input: string | Readable; inputOptions: string[]; cleanup(): void; /** Resolves with a reason if the downloader died on its own, for reporting. */ failure?: Promise; } const HTTP_RESILIENCE = [ "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", ]; /** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */ export async function openPlayback(track: Track, seekSeconds = 0): Promise { const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; if (track.source === "local") { const filePath = await local.assertInsideLibrary(track.url); return { input: filePath, inputOptions: seekOptions, cleanup: () => {} }; } if (track.source === "direct") { return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; } if (seekSeconds > 0) { // Seeking over a pipe would mean decoding everything up to the offset, so we // resolve the CDN URL instead and let ffmpeg do an HTTP range request. const streamUrl = await ytdlp.resolveStreamUrl(track.url); return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; } const proc = ytdlp.openAudioStream(track.url); return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill(), failure: proc.failure, }; }