지금까지 내용 커밋

This commit is contained in:
2026-04-08 12:59:45 +09:00
commit b0dae31cb9
68 changed files with 12083 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
/// <reference types="spotify-api" />
import "dotenv/config";
import { Logger } from "../Logger";
import { SongItem } from "../../types/Track";
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_URL = "https://api.spotify.com/v1";
const TOKENR_URL = "http://192.168.10.5:8075/api/token";
const searchCache = new Map<string, string>();
type TOKENER_RESPONSE = {
clientId: string;
accessToken: string;
accessTokenExpirationTimestampMs: number;
isAnonymous: boolean;
_notes: string;
};
export const Spotify = {
_token: {
cached: "",
expiresAt: 0,
},
async getTokenByTokener(): Promise<string | null> {
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached;
try {
const response = await fetch(TOKENR_URL);
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
const jsonData = await response.json() as TOKENER_RESPONSE;
this._token.cached = jsonData.accessToken;
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs;
return jsonData.accessToken;
} catch (err) {
Logger.error(`토크너 접근 실패: ${err}`);
return null;
}
},
async getToken(): Promise<string | null> {
try {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: SPOTIFY_CLIENTID,
client_secret: SPOTIFY_SECRET,
}),
});
const textData = await response.text();
if (!response.ok) {
Logger.error(`스포티파이 정식 토큰 발급 에러 (${response.status}): ${textData}`);
return null;
}
const jsonData = JSON.parse(textData);
this._token.cached = jsonData.access_token;
// expires_in은 초(초당 1000ms) 단위입니다. 만료 1분 전(60000ms)에 미리 갱신되게 세팅!
this._token.expiresAt = Date.now() + (jsonData.expires_in * 1000) - 60000;
Logger.info("🟢 [Spotify] 공식 API 키로 정식 토큰 발급 완료!");
return jsonData.access_token;
} catch (err) {
Logger.error(`스포티파이 토큰 요청 실패: ${err}`);
return null;
}
},
async getSearchFull(query: string): Promise<SongItem[]> {
try {
const token = await this.getToken();
if (!token) return [];
const response = await fetch(
`${SPOTIFY_API_URL}/search?q=${encodeURIComponent(query)}&type=track&market=KR&limit=1`,
{
headers: {
Authorization: `Bearer ${token}`,
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
},
},
);
const textData = await response.text();
if (!response.ok) {
if (response.status === 429) {
Logger.warn("⚠️ 스포티파이 요청 제한(Rate Limit)에 걸렸습니다.");
} else {
Logger.error(`스포티파이 API 에러 (${response.status}): ${textData}`);
}
return [];
}
const data = JSON.parse(textData) as SpotifyApi.TrackSearchResponse;
if (!data.tracks || data.tracks.items.length === 0) return [];
return data.tracks.items.map((track) => ({
videoId: track.id,
title: track.name,
artist: track.artists.map(artist => artist.name).join(", "),
duration: track.duration_ms,
thumbnail: track.album.images[2]?.url,
}));
} catch (err) {
Logger.error(`스포티파이 검색 실패: ${err}`);
return [];
}
},
async getSearchUrl(query: string): Promise<string | null> {
const lowerQuery = query.toLocaleLowerCase().trim();
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
const track = (await this.getSearchFull(query) ?? [])?.[0];
const url = track?.videoId ? `https://open.spotify.com/track/${track.videoId}` : null;
if (url) searchCache.set(lowerQuery, url);
return url;
}
}

View File

@@ -0,0 +1,216 @@
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";
const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080";
export const ORIGIN = "https://music.youtube.com";
const proxy = new ProxyAgent('http://192.168.10.4:3128');
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) {
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies));
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[]> {
console.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="
}),
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({
title,
artist,
videoId,
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({
title,
artist,
videoId,
thumbnail,
duration: parseDurationToMs(durationStr || "")
});
}
}
}
}
return results || []; // 배열이 비어있을 경우 안전하게 null 반환
} catch (error) {
console.error("❌ getSearchFull 실행 중 에러:", 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];
const url = video?.videoId ? `https://music.youtube.com/watch?v=${video.videoId}` : null;
if (url) searchCache.set(lowerQuery, url);
return url;
}
};