import { fetch, ProxyAgent } from "undici"; import crypto from "node:crypto"; import { Cookies } from "../../types/Youtube_Cookie"; import { Config } from "../Config"; import { SongItem } from "../../types/Track"; import { Logger } from "../Logger"; const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080"; export const ORIGIN = "https://music.youtube.com"; const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined; const searchCache = new Map(); // ๐ŸŒŸ ํด๋ž˜์Šค ์™ธ๋ถ€์— ๋‘˜ ์ƒ์ˆ˜ ๋ฐ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค (๋‚ด๋ถ€์—์„œ๋งŒ ์‚ฌ์šฉ๋จ) const defaultCookies: Cookies[] = [ Cookies.SAPISID, // ํ•ด์‹œ ์ƒ์„ฑ์šฉ ์”จ์•— 1 Cookies.Secure3PAPISID, // ํ•ด์‹œ ์ƒ์„ฑ์šฉ ์”จ์•— 2 Cookies.SID, // ๋ฉ”์ธ ์„ธ์…˜ ์‹ ๋ถ„์ฆ Cookies.Secure3PSID, // API์šฉ ๋ฉ”์ธ ์‹ ๋ถ„์ฆ (ํ•„์ˆ˜) Cookies.Secure1PSIDTS, // ์„ธ์…˜ ์‹ ์„ ๋„ ๊ฒ€์ฆ ํƒ€์ž„์Šคํƒฌํ”„ Cookies.PREF // ์ง€์—ญ/์–ธ์–ด ์„ค์ • ]; /** * "MM:SS" ๋˜๋Š” "HH:MM:SS" ํ˜•ํƒœ์˜ ๋ฌธ์ž์—ด์„ ๋ฐ€๋ฆฌ์ดˆ(ms)๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. */ function parseDurationToMs(timeStr: string): number { if (!timeStr) return 0; const parts = timeStr.split(':').reverse(); // [์ดˆ, ๋ถ„, ์‹œ๊ฐ„] let ms = 0; if (parts[0]) ms += parseInt(parts[0], 10) * 1000; if (parts[1]) ms += parseInt(parts[1], 10) * 60 * 1000; if (parts[2]) ms += parseInt(parts[2], 10) * 60 * 60 * 1000; return isNaN(ms) ? 0 : ms; } // ๐ŸŒŸ Spotify.ts์™€ ๋™์ผํ•˜๊ฒŒ Youtube ๊ฐ์ฒด ํ•˜๋‚˜๋กœ ๋ชจ๋“  ๊ธฐ๋Šฅ์„ ๋ฌถ์–ด์„œ export ํ•ฉ๋‹ˆ๋‹ค. export const YoutubeMusic = { getCookieJson(keys: Cookies[] = defaultCookies, blocks?: Cookies[]): { [key: string]: string } { const cookies = Object.fromEntries( Config.youtube_cookie .split(";") .map(v => v.trim()) .filter(v => v.includes("=")) .map(v => { const [k, ...vs] = v.split("="); return [k.trim(), vs.join("=").trim()]; }) ); // ํ•œ๊ตญ ๋งž์ถคํ˜• ์ง€์—ญ/์„ค์ • ๊ฐ•์ œ ์ฃผ์ž… cookies["PREF"] = customPREF; const allows = keys.filter( (k) => k in cookies && !(blocks ?? []).includes(k) ); const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k)); if (missing.length > 0) { Logger.warn(`ํ˜„์žฌ ์ž…๋ ฅ๋œ ์ฟ ํ‚ค ํ‚ค ๋ชฉ๋ก: ${Object.keys(cookies).join(", ")}`); throw new Error(`โŒ ํ•„์ˆ˜ ์ธ์ฆ ์ฟ ํ‚ค๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: ${missing.join(", ")}`); } return Object.fromEntries(allows.map((k) => [k, cookies[k]])); }, getCookie(keys: Cookies[] = defaultCookies, blocks?: Cookies[]): string { // ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์‹œ this ์‚ฌ์šฉ const json = this.getCookieJson(keys, blocks); return Object.entries(json).map(([k, v]) => `${k}=${v}`).join("; "); }, getAuthorization() { // ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์‹œ this ์‚ฌ์šฉ const sapisid = this.getCookieJson([Cookies.Secure3PAPISID])["__Secure-3PAPISID"]; const t = Math.floor(Date.now() / 1000); const h = crypto.createHash("sha1").update(`${t} ${sapisid} ${ORIGIN}`).digest("hex"); return `SAPISIDHASH ${t}_${h} SAPISID1PHASH ${t}_${h} SAPISID3PHASH ${t}_${h}`; }, /** * ์™„๋ฒฝํ•œ ์ฟ ํ‚ค ์ธ์ฆ๊ณผ ์„œ๋ช…(SAPISIDHASH)์„ ์‚ฌ์šฉํ•˜์—ฌ ์œ ํŠœ๋ธŒ ๋ฎค์ง ๊ฒ€์ƒ‰์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. */ async getSearchFull(query: string): Promise { Logger.log(`๐Ÿ” [Auth-Cookie Engine] "${query}" ๋ฐ์ดํ„ฐ ์ถ”์ถœ ์ค‘ (์ธ๋„ค์ผ, ์žฌ์ƒ์‹œ๊ฐ„ ํฌํ•จ)...`); const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': this.getCookie(), // this ๋ฐ”์ธ๋”ฉ ์ ์šฉ 'Authorization': this.getAuthorization(), // this ๋ฐ”์ธ๋”ฉ ์ ์šฉ 'Origin': 'https://music.youtube.com', 'Referer': `https://music.youtube.com/search?q=${encodeURIComponent(query)}`, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.7680.178 Safari/537.36' }, body: JSON.stringify({ context: { client: { clientName: "WEB_REMIX", clientVersion: "1.20240320.01.00", hl: "ko", gl: "KR" } }, query: query, params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE=" }), ...(proxy ? { dispatcher: proxy } : {}) }); const data: any = await response.json(); if (data.error) { throw new Error(`์„œ๋ฒ„ ์—๋Ÿฌ (${data.error.code}): ${data.error.message}`); } const results: SongItem[] = []; const tabs = data.contents?.tabbedSearchResultsRenderer?.tabs || []; const sections = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents || []; for (const section of sections) { // 1. ์ตœ์ƒ๋‹จ 'Top Result' ์นด๋“œ ํŒŒ์‹ฑ if (section.musicCardShelfRenderer) { const card = section.musicCardShelfRenderer; const title = card.title?.runs?.[0]?.text; const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId; let artist = "Unknown Artist"; let durationStr = ""; if (card.subtitle?.runs) { const validRuns = card.subtitle.runs.map((r: any) => r.text).filter((t: string) => t !== " โ€ข " && t !== "๋…ธ๋ž˜" && t !== "๋™์˜์ƒ"); if (validRuns.length > 0) artist = validRuns[0]; const timeMatch = validRuns.find((t: string) => /^\d+:\d+/.test(t)); if (timeMatch) durationStr = timeMatch; } const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || []; const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : ""; if (videoId && title) { results.push({ videoId, url: `https://music.youtube.com/watch?v=${videoId}`, title, artist, thumbnail, duration: parseDurationToMs(durationStr) }); } } // 2. ์ผ๋ฐ˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๋ชฉ๋ก ํŒŒ์‹ฑ if (section.musicShelfRenderer) { const items = section.musicShelfRenderer.contents || []; for (const item of items) { const track = item.musicResponsiveListItemRenderer; if (!track) continue; const videoId = track.playlistItemData?.videoId; const title = track.flexColumns?.[0]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.[0]?.text; let artist = "Unknown Artist"; const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || []; if (subtitleRuns.length >= 3) { artist = subtitleRuns[2]?.text || artist; } else if (subtitleRuns.length === 1) { artist = subtitleRuns[0]?.text || artist; } let durationStr = track.fixedColumns?.[0]?.musicResponsiveListItemFixedColumnRenderer?.text?.runs?.[0]?.text; if (!durationStr) { const timeRun = subtitleRuns.find((r: any) => /^\d+:\d+/.test(r.text)); if (timeRun) durationStr = timeRun.text; } const thumbnails = track.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || []; const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : ""; if (videoId && title) { results.push({ videoId, url: `https://music.youtube.com/watch?v=${videoId}`, title, artist, thumbnail, duration: parseDurationToMs(durationStr || "") }); } } } } return results; } catch (error) { Logger.error(`โŒ getSearchFull ์‹คํ–‰ ์ค‘ ์—๋Ÿฌ: ${String(error)}`); return []; } }, async getSearchUrl(query: string): Promise { const lowerQuery = query.toLocaleLowerCase().trim(); if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null; const video = (await this.getSearchFull(query) ?? [])?.[0]; if (video?.url) searchCache.set(lowerQuery, video.url); return video?.url ?? null; } };