지금까지 내용 커밋
This commit is contained in:
89
bot/src/utils/Config.ts
Normal file
89
bot/src/utils/Config.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import "dotenv/config";
|
||||
import { ColorResolvable, Colors } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const Config = {
|
||||
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
|
||||
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
|
||||
|
||||
_prefix: process.env.PREFIX?.trim(),
|
||||
get prefix() {
|
||||
if (!this._prefix) throw new ReferenceError("PREFIX is missing");
|
||||
return this._prefix;
|
||||
},
|
||||
|
||||
_appId: process.env.APPID?.trim(),
|
||||
get appId() {
|
||||
if (!this._appId) throw new ReferenceError("APPID is missing");
|
||||
return this._appId;
|
||||
},
|
||||
|
||||
_token: process.env.TOKEN?.trim(),
|
||||
get token() {
|
||||
if (!this._token) throw new ReferenceError("TOKEN is missing");
|
||||
return this._token;
|
||||
},
|
||||
|
||||
_dbPath: process.env.DBPATH?.trim(),
|
||||
get dbPath() {
|
||||
if (!this._dbPath) throw new ReferenceError("DBPATH is missing");
|
||||
return this._dbPath;
|
||||
},
|
||||
|
||||
_guildId: process.env.GUILDID?.trim(),
|
||||
get guildId() {
|
||||
if (!this._guildId) throw new ReferenceError("GUILDID is missing");
|
||||
return this._guildId;
|
||||
},
|
||||
|
||||
_embedColor: process.env.EMBEDCOLOR?.trim(),
|
||||
get embedColor() {
|
||||
if (!this._embedColor) throw new ReferenceError("EMBEDCOLOR is missing");
|
||||
const list = Object.keys(Colors);
|
||||
if (!list.includes(this._embedColor)) throw new TypeError(`EMBEDCOLOR TYPE ERROR: ${list.join(",")}`);
|
||||
return this._embedColor as ColorResolvable;
|
||||
},
|
||||
|
||||
_lavalink: {
|
||||
host: process.env.LAVALINK_HOST?.trim(),
|
||||
port: process.env.LAVALINK_PORT?.trim(),
|
||||
pw: process.env.LAVALINK_PW?.trim(),
|
||||
},
|
||||
get lavalink() {
|
||||
if (!this._lavalink.host) throw new ReferenceError("LAVALINK_HOST is missing");
|
||||
if (!this._lavalink.port) throw new ReferenceError("LAVALINK_PORT is missing");
|
||||
if (!this._lavalink.pw) throw new ReferenceError("LAVALINK_PW is missing");
|
||||
const port = Number(this._lavalink.port!);
|
||||
if (isNaN(port)) throw new TypeError("LAVALINK_PORT must be a number");
|
||||
return {
|
||||
host: this._lavalink.host,
|
||||
port: port,
|
||||
pw: this._lavalink.pw,
|
||||
}
|
||||
},
|
||||
|
||||
_youtube_cookie: process.env.YOUTUBE_COOKIE?.trim(),
|
||||
get youtube_cookie() {
|
||||
if (!this._youtube_cookie) throw new ReferenceError("YOUTUBE_COOKIE is missing");
|
||||
if (this._youtube_cookie.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1);
|
||||
return this._youtube_cookie;
|
||||
},
|
||||
|
||||
_redis: {
|
||||
host: process.env.REDIS_HOST?.trim(),
|
||||
port: process.env.REDIS_PORT?.trim(),
|
||||
},
|
||||
get redis() {
|
||||
if (!this._redis.host) throw new ReferenceError("REDIS_HOST is missing");
|
||||
if (!this._redis.port) throw new ReferenceError("REDIS_PORT is missing");
|
||||
const port = Number(this._redis.port!);
|
||||
if (isNaN(port)) throw new TypeError("REDIS_PORT must be a number");
|
||||
return {
|
||||
host: this._redis.host,
|
||||
port: port,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const COMMANDS_PATH = join(__dirname, "..", "commands");
|
||||
export const COMMAND_PATH = (commandFile: string) => join(COMMANDS_PATH, commandFile);
|
||||
117
bot/src/utils/Database.ts
Normal file
117
bot/src/utils/Database.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Config } from "./Config";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { GuildRow, GuildType } from "../../db/db";
|
||||
import { Logger } from "./Logger";
|
||||
|
||||
const database = new Database(Config.dbPath);
|
||||
|
||||
const schemaPath = join(process.cwd(), "db/schema.sql");
|
||||
const schema = readFileSync(schemaPath, "utf-8");
|
||||
database.exec(schema);
|
||||
|
||||
Logger.ready("DB 활성화!");
|
||||
|
||||
const stmt = {
|
||||
guild: {
|
||||
// 전체
|
||||
all: database.prepare("SELECT * FROM guilds"),
|
||||
// 가져오기
|
||||
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
|
||||
// 추가
|
||||
insert: (data: GuildRow) => {
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
return database.prepare(`INSERT INTO guilds (${
|
||||
keys.map(k => `"${k}"`).join(", ")
|
||||
}) VALUES (${
|
||||
keys.map(k => `@${k}`).join(", ")
|
||||
})`).run(data);
|
||||
},
|
||||
// 수정
|
||||
update: (data: GuildRow) => {
|
||||
const keys = Object.keys(data).filter(k => k !== "id");
|
||||
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
return database.prepare(`UPDATE guilds SET ${
|
||||
keys.map(k => `${k} = @${k}`).join(", ")
|
||||
} WHERE id = @id`).run(data);
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// // 가져오기
|
||||
// get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"),
|
||||
// // 추가
|
||||
// insert: (data: UserType) => {
|
||||
// const keys = Object.keys(data);
|
||||
// if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
// return database.prepare(`INSERT INTO users (${
|
||||
// keys.map(k => `"${k}"`).join(", ")
|
||||
// }) VALUES (${
|
||||
// keys.map(k => `@${k}`).join(", ")
|
||||
// })`).run(data);
|
||||
// },
|
||||
// // 수정
|
||||
// update: (data: UserType) => {
|
||||
// const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
||||
// if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
// return database.prepare(`UPDATE users SET ${
|
||||
// keys.map(k => `${k} = @${k}`).join(", ")
|
||||
// } WHERE guild_id = @guild_id AND id = @id`).run(data);
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export const DB = {
|
||||
guild: {
|
||||
all() {
|
||||
return stmt.guild.all.all() as GuildType[];
|
||||
},
|
||||
get(id: string) {
|
||||
const row = stmt.guild.get.get(id) as GuildRow | undefined;
|
||||
if (!row) return undefined;
|
||||
return { ...row, options: JSON.parse(row.options) } as GuildType;
|
||||
},
|
||||
set(data: GuildType) {
|
||||
try {
|
||||
stmt.guild.insert({ ...data, options: JSON.stringify(data.options) });
|
||||
return true;
|
||||
} catch (err) {
|
||||
Logger.error(String(err));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
update(data: GuildType) {
|
||||
try {
|
||||
stmt.guild.update({ ...data, options: JSON.stringify(data.options) });
|
||||
return true;
|
||||
} catch (err) {
|
||||
Logger.error(String(err));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// get(guildId: string, id: string) {
|
||||
// return stmt.user.get.get(guildId, id) as UserType | undefined;
|
||||
// },
|
||||
// set(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.insert(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// update(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.update(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
};
|
||||
38
bot/src/utils/Logger.ts
Normal file
38
bot/src/utils/Logger.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import colors from "colors/safe";
|
||||
|
||||
export const Timestamp = () => {
|
||||
const Now = new Date();
|
||||
Now.setHours(Now.getHours() + 9);
|
||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
||||
}
|
||||
|
||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
||||
|
||||
const log = (content: string, type: logType) => {
|
||||
const timestamp = colors.white(`[${Timestamp()}]`);
|
||||
switch (type) {
|
||||
case "log":
|
||||
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
|
||||
case "info":
|
||||
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
|
||||
case "warn":
|
||||
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
|
||||
case "error":
|
||||
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
|
||||
case "debug":
|
||||
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
|
||||
case "ready":
|
||||
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
|
||||
default:
|
||||
throw new TypeError("Logger 타입이 올바르지 않습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
export const Logger = {
|
||||
log: (content: string) => log(content, "log"),
|
||||
warn: (content: string) => log(content, "warn"),
|
||||
error: (content: string) => log(content, "error"),
|
||||
debug: (content: string) => log(content, "debug"),
|
||||
info: (content: string) => log(content, "info"),
|
||||
ready: (content: string) => log(content, "ready")
|
||||
}
|
||||
130
bot/src/utils/api/Spotify.ts
Normal file
130
bot/src/utils/api/Spotify.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
216
bot/src/utils/api/YoutubeMusic.ts
Normal file
216
bot/src/utils/api/YoutubeMusic.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
39
bot/src/utils/music/Button.ts
Normal file
39
bot/src/utils/music/Button.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ButtonInteraction, Guild } from "discord.js";
|
||||
import { lavalinkManager } from "../../index";
|
||||
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
|
||||
import { default_content, default_embed, default_image, getButtons } from "./Config";
|
||||
import { DB } from "../Database";
|
||||
|
||||
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
|
||||
if (!interaction.guild) return;
|
||||
let player = lavalinkManager.getPlayer(interaction.guild.id);
|
||||
if (player) {
|
||||
if (args[0] === "pause") player.setPause();
|
||||
if (args[0] === "stop") player.stop();
|
||||
if (args[0] === "skip") player.skip();
|
||||
if (args[0] === "shuffle") player.setShuffle();
|
||||
if (args[0] === "recommend") player.setRecommend();
|
||||
} else {
|
||||
if (args[0] === "recommend") buttonRecommend(interaction.guild);
|
||||
}
|
||||
return interaction.deferUpdate().catch(() => {});
|
||||
}
|
||||
|
||||
const buttonRecommend = async (guild: Guild) => {
|
||||
const gdb = DB.guild.get(guild.id);
|
||||
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
|
||||
if (!change) return;
|
||||
const { channel: getChannel, msg: getMsg, reason } = await getTextChannelAndMsg(guild);
|
||||
if (reason || !getChannel || !getMsg) return;
|
||||
const { msg, check } = await checkTextChannelAndMsg(guild, getChannel, getMsg);
|
||||
if (!check) return;
|
||||
await msg.edit({
|
||||
content: default_content,
|
||||
embeds: [ default_embed(guild.id) ],
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
54
bot/src/utils/music/Channel.ts
Normal file
54
bot/src/utils/music/Channel.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ChannelType, Guild, GuildMember, Message, TextChannel, VoiceChannel } from "discord.js";
|
||||
import { DB } from "../Database";
|
||||
import { Config } from "../Config";
|
||||
import { Logger } from "../Logger";
|
||||
import { clearAllMsg } from "./Utils";
|
||||
|
||||
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||
if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel;
|
||||
if (member.guild.members.me?.voice.channel?.type === ChannelType.GuildVoice) return member.guild.members.me.voice.channel;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getVoiceChannelById = async(guild: Guild, userId: string): Promise<VoiceChannel | null> => {
|
||||
if (!guild) return null;
|
||||
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||
if (!member) return null;
|
||||
return getVoiceChannel(member);
|
||||
}
|
||||
|
||||
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {
|
||||
const gdb = DB.guild.get(guild.id);
|
||||
const channelId = gdb?.channel_id;
|
||||
if (!channelId) return { reason: "DB불러오기 오류" };
|
||||
const channel = guild.channels.cache.get(channelId);
|
||||
if (!channel?.id) return { reason: "채팅 채널 가져오기 오류" };
|
||||
if (channel.type !== ChannelType.GuildText) return { reason: `<#${channelId}> 채팅 채널이 아닙니다.` };
|
||||
const msgId = gdb?.msg_id;
|
||||
if (!msgId) return { channel, reason: "DB불러오기 오류" };
|
||||
const msg = channel.messages.cache.get(msgId);
|
||||
if (!msg?.id) return { channel, reason: "메세지 가져오기 오류" };
|
||||
return { channel, msg };
|
||||
}
|
||||
|
||||
export const checkTextChannelAndMsg = async (guild: Guild, getChannel: TextChannel, getMsg: Message): Promise<{ channel: TextChannel; msg: Message; check: boolean; }> => {
|
||||
const channel = await getChannel.fetch(true).catch((err) => {
|
||||
if (Config.debug) Logger.error(`checkTextChannelAndMsg (textChannel (fetch)): ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
if (!channel || channel.type !== ChannelType.GuildText) return { channel: getChannel, msg: getMsg, check: false };
|
||||
let msg = await getMsg?.fetch(true).catch((err) => {
|
||||
if (Config.debug) Logger.error(`checkTextChannelAndMsg (msg (fetch)): ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
if (!msg) {
|
||||
if (Config.debug) Logger.info(`${guild.name} msg 재생성`);
|
||||
await clearAllMsg(channel);
|
||||
msg = await channel.send({ content: "메세지 재생성중..." }).catch((err) => {
|
||||
if (Config.debug) Logger.error(`checkTextChannelAndMsg (channel (send)): ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (!msg) return { channel, msg: getMsg, check: false };
|
||||
return { channel, msg, check: true };
|
||||
}
|
||||
73
bot/src/utils/music/Config.ts
Normal file
73
bot/src/utils/music/Config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EmbedBuilder, AttachmentBuilder, ButtonStyle, ButtonBuilder, APIMessageComponentEmoji, ActionRowBuilder } from "discord.js";
|
||||
import { lavalinkManager } from "../../index";
|
||||
import { Config } from "../Config";
|
||||
import { DB } from "../Database";
|
||||
|
||||
export const default_content = [
|
||||
"__**대기열 목록:**__",
|
||||
"음성 채널에 참여한 후 노래제목 혹은 url로 노래를 대기열에 추가하세요.",
|
||||
].join("\n");
|
||||
|
||||
export const default_image = new AttachmentBuilder("./images/default_img.png");
|
||||
|
||||
export const default_embed = (guildId: string) => new EmbedBuilder()
|
||||
.setTitle("**현재 노래가 재생되지 않았습니다.**")
|
||||
.setImage("attachment://default_img.png")
|
||||
.setFooter({ text: `PREFIX: ${Config.prefix} | 볼륨: ${lavalinkManager.getPlayer(guildId)?.player.volume ?? 50}${
|
||||
DB.guild.get(guildId)?.options.recommend ? " | 자동재생: 활성화" : ""
|
||||
}` })
|
||||
.setColor(Config.embedColor);
|
||||
|
||||
export const loading_embed = new EmbedBuilder()
|
||||
.setTitle("**재생 준비중...**")
|
||||
.setImage("attachment://default_img.png")
|
||||
.setFooter({ text: `PREFIX: ${Config.prefix}` })
|
||||
.setColor(Config.embedColor);
|
||||
|
||||
const btn = (
|
||||
id: string,
|
||||
emoji: string | APIMessageComponentEmoji,
|
||||
style: ButtonStyle,
|
||||
disabled: boolean = false
|
||||
) => new ButtonBuilder()
|
||||
.setCustomId(id)
|
||||
.setEmoji(typeof emoji === "string" ? { name: emoji } : emoji)
|
||||
.setStyle(style)
|
||||
.setDisabled(disabled);
|
||||
|
||||
export const getButtons = (
|
||||
playing?: boolean,
|
||||
pause?: boolean,
|
||||
list?: boolean
|
||||
) => new ActionRowBuilder<ButtonBuilder>().addComponents(...[
|
||||
btn(
|
||||
"music-pause",
|
||||
"⏯️",
|
||||
pause ? ButtonStyle.Primary : ButtonStyle.Success,
|
||||
!playing
|
||||
),
|
||||
btn(
|
||||
"music-stop",
|
||||
"⏹️",
|
||||
ButtonStyle.Danger,
|
||||
false
|
||||
),
|
||||
btn(
|
||||
"music-skip",
|
||||
"⏭️",
|
||||
ButtonStyle.Secondary,
|
||||
!playing
|
||||
),
|
||||
btn(
|
||||
"music-shuffle",
|
||||
"🔀",
|
||||
ButtonStyle.Secondary,
|
||||
!(playing && list)
|
||||
),
|
||||
btn(
|
||||
"music-recommend",
|
||||
{ id: "1035604533532954654", name: "auto" },
|
||||
ButtonStyle.Secondary,
|
||||
false
|
||||
)
|
||||
]);
|
||||
16
bot/src/utils/music/Url.ts
Normal file
16
bot/src/utils/music/Url.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const parseLink = (text: string): { isUrl: boolean; text: string; flags: Set<string>; } => {
|
||||
const isUrl = text.trim().startsWith("http");
|
||||
const parts = text.trim().split(/\s+/);
|
||||
const flags = new Set<string>();
|
||||
while (parts.length > 0) {
|
||||
const last = parts[parts.length-1];
|
||||
if (/^-[A-Za-z]$/.test(last)) {
|
||||
flags.add(last[1].toLocaleLowerCase()); // "-s" → "s"
|
||||
parts.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
text = parts.join(" ").trim();
|
||||
return { isUrl, text, flags };
|
||||
}
|
||||
42
bot/src/utils/music/Utils.ts
Normal file
42
bot/src/utils/music/Utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Collection, Message, TextChannel } from "discord.js";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
|
||||
|
||||
export const clearAllMsg = async (channel: TextChannel) => {
|
||||
Logger.log(`${channel.guild.name} : ${channel.name} 메세지 전부 삭제`);
|
||||
let fetched: Collection<string, Message>;
|
||||
do {
|
||||
fetched = await channel.messages.fetch({ limit: 100 });
|
||||
if (fetched.size === 0) break;
|
||||
for (const m of fetched.values()) {
|
||||
try {
|
||||
await m.delete();
|
||||
await sleep(200);
|
||||
} catch (err: any) {
|
||||
const retry = (err?.retry_after ?? err?.data?.retry_after ?? err?.rawError?.retry_after) as number | undefined;
|
||||
if (retry) {
|
||||
const waitMs = Math.ceil(retry*1000);
|
||||
Logger.log(`429 레이트리밋 감지: ${waitMs}ms 대기`);
|
||||
await sleep(waitMs);
|
||||
try {
|
||||
await m.delete();
|
||||
await sleep(200);
|
||||
} catch (err2) {
|
||||
Logger.error(`메세지 ${m.id} 삭제 재시도 실패:`+String(err2));
|
||||
}
|
||||
} else {
|
||||
Logger.error(`메세지 ${m.id} 삭제 실패:`+String(err));
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (fetched.size > 0);
|
||||
Logger.log(`${channel.guild.name} : ${channel.name} 메세지 전부 삭제 완료!`);
|
||||
}
|
||||
|
||||
export const timeFormat = (num: number): string => {
|
||||
num = Math.floor(num/1000);
|
||||
if (num < 60) return "00:"+num.toString().padStart(2, '0');
|
||||
return Math.floor(num/60).toString().padStart(2, '0')+':'+(num%60).toString().padStart(2, '0');
|
||||
}
|
||||
15
bot/src/utils/shuffle.ts
Normal file
15
bot/src/utils/shuffle.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const fshuffle = (list: any[]): any[] => {
|
||||
var i, j, x;
|
||||
for (i=list.length; i; i-=1) {
|
||||
j = Math.floor(Math.random()*i);
|
||||
x = list[i-1];
|
||||
list[i-1] = list[j];
|
||||
list[j] = x;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export const shuffle = (list: any[]): any[] => {
|
||||
for (let z=0; z<5; z++) list = fshuffle(list);
|
||||
return list;
|
||||
}
|
||||
Reference in New Issue
Block a user