Document YouTube cookie auth and warn when the file is unusable
Cookies were already wired up but only mentioned in passing, and the non-obvious parts were undocumented: yt-dlp has no username/password support for YouTube, it rewrites the cookie file to persist rotated cookies (so a read-only file expires early), and the export has to happen in a private window that is logged out before closing. Startup now reports whether the cookie file is usable, missing, or read-only, and YTDLP_EXTRACTOR_ARGS is passed through for the cases where YouTube blocks a server IP outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1ac99b4a10
commit
2b999bf2a0
+143
-143
@@ -1,143 +1,143 @@
|
||||
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 } 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<SearchResult> {
|
||||
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<Track[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
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<PlaybackInput> {
|
||||
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() };
|
||||
}
|
||||
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<SearchResult> {
|
||||
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<Track[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
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<PlaybackInput> {
|
||||
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() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user