Files
music_bot_v2/bot/src/utils/api/Spotify.ts
claude-bot d0dcdb1563 bot 전체 코드 품질 개선 및 버그 수정
- 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>
2026-04-26 23:13:16 +09:00

130 lines
4.4 KiB
TypeScript

/// <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 = process.env.SPOTIFY_TOKENER_URL?.trim() || "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,
url: `https://open.spotify.com/track/${track.id}`,
title: track.name,
artist: track.artists.map(artist => artist.name).join(", "),
duration: track.duration_ms,
thumbnail: track.album.images[0]?.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];
if (track?.url) searchCache.set(lowerQuery, track.url);
return track?.url ?? null;
}
}