- GuildPlayer: 타이머 레이스 컨디션 수정, 모든 타이머 정리 로직 통합 (clearAllTimers) - GuildPlayer: 이벤트 핸들러에 try-catch 추가 (end, exception, stuck) - GuildPlayer: start 이벤트에서 endTimer 정리, autoPlay tracks 길이 검증 추가 - RedisClient: player_seek, player_volume에 누락된 return ���가 - RedisClient: queue_remove 인덱스 검증 주석 명확화 - Handler: runCommand에 try-catch 추가하여 에러 시 사용자에게 응답 - Channel: getGuildById에 누락된 await 추가, getMemberById/getVoiceChannelById 안전한 에러 처리 - Command.d.ts: 잘못된 타입 ChatInputChatInputCommandInteraction → ChatInputCommandInteraction 수정 - join.ts: 채널 멘션 닫는 괄호 누락 수정 - shuffle.ts: 제네릭 타입 적용, 불필요한 5회 반복 제거 - import 경로 대소문자 수정 (Shuffle → shuffle) - Linux 호환 - YoutubeMusic/Spotify: 하드코딩된 IP를 환경변수로 분리 - console.log/error → Logger 통일 (YoutubeMusic, Button, channel) - interactionCreate: 전체 try-catch 추가, silent catch에 로깅 추가 - Database: schema 경로 __dirname 기반으로 수정, 컬럼 화이트리스트 추가 - 사용하지 않는 코드 정리 (axios 의존성, 주석처리된 user 관련 코드) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
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<string, string>();
|
|
|
|
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
|
|
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<SongItem[]> {
|
|
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<string | null> {
|
|
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;
|
|
}
|
|
}; |