지금까지 내용 커밋
This commit is contained in:
6
bot/.dockerignore
Normal file
6
bot/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
7
bot/.gitignore
vendored
Normal file
7
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
*.env
|
||||
dist/
|
||||
*.db
|
||||
src/test.ts
|
||||
test/
|
||||
ytdlp/
|
||||
20
bot/Dockerfile
Normal file
20
bot/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p dist
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
16
bot/db/db.d.ts
vendored
Normal file
16
bot/db/db.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface GuildType {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_id: string;
|
||||
msg_id: string;
|
||||
options: {
|
||||
recommend: boolean;
|
||||
};
|
||||
}
|
||||
export type GuildRow = Omit<GuildType, "options"> & { options: string };
|
||||
|
||||
// export interface UserType {
|
||||
// guild_id: string;
|
||||
// id: string;
|
||||
// name: string;
|
||||
// }
|
||||
24
bot/db/schema.sql
Normal file
24
bot/db/schema.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 외래키 제약 조건 활성화
|
||||
-- 기본적으로 SQLite는 외래키 검사 안함 그래서 켜줘야 함
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guilds (
|
||||
id TEXT PRIMARY KEY, -- 길드 ID (전역 유일값)
|
||||
name TEXT NOT NULL, -- 길드 이름 (캐싱용)
|
||||
channel_id TEXT NOT NULL, -- 채팅 ID
|
||||
msg_id TEXT NOT NULL, -- 메세지 ID
|
||||
options TEXT NOT NULL -- 옵션 JSON
|
||||
);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS users (
|
||||
-- guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조
|
||||
-- id TEXT NOT NULL, -- 유저 ID
|
||||
-- name TEXT NOT NULL, -- 유저 이름 (캐싱용)
|
||||
|
||||
-- -- 복합 기본키: 같은 길드 안에서 id는 중복 불가
|
||||
-- PRIMARY KEY (guild_id, id),
|
||||
|
||||
-- -- 외래키 설정: guilds.id를 참조
|
||||
-- -- 길드가 삭제되면 소속된 유저도 자동으로 삭제됨 (ON DELETE CASCADE)
|
||||
-- FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||
-- );
|
||||
5
bot/docs/create_potoken.md
Normal file
5
bot/docs/create_potoken.md
Normal file
@@ -0,0 +1,5 @@
|
||||
node 22.21.0
|
||||
|
||||
```
|
||||
npx youtube-po-token-generator
|
||||
```
|
||||
BIN
bot/images/default_img.png
Normal file
BIN
bot/images/default_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
1453
bot/package-lock.json
generated
Normal file
1453
bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
bot/package.json
Normal file
32
bot/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "music_bot_v2",
|
||||
"version": "0.0.1",
|
||||
"description": "discord music bot with lavalink",
|
||||
"license": "ISC",
|
||||
"author": "tkrmagid",
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node .",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "ts-node src/test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/spotify-api": "^0.0.26",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"colors": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"shoukaku": "^4.3.0",
|
||||
"undici": "^7.24.7"
|
||||
}
|
||||
}
|
||||
522
bot/result.ts
Normal file
522
bot/result.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
let result = [
|
||||
{
|
||||
"encoded": "QAABnwMADEhvbGQgbXkgaGFuZAACSVUAAAAAAAL6kAAWMEtWQ2xYeHNaRUtreVdSTlhlUkZyRQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svMEtWQ2xYeHNaRUtreVdSTlhlUkZyRQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNmMWVmYjQ2N2FjNGM3NDg2MzBmZmQyMmYBAAxLUkEzODEwMDE1NjEAB3Nwb3RpZnkBABdNeSBMYXN0IExvdmUgT1NUIFBhcnQuNAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS81OENCd005OFkzNTZ6RDVBVkdaa1pHAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8zSHFTTE1BWjNnM2Q1cG9OYUk3R09VAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNjEwMDAwZTVlYjc4OWYzODA0MmU1ZWY4OTExZmMzODI2YgAAAAAAAAAAAAA=",
|
||||
"info": {
|
||||
"identifier": "0KVClXxsZEKkyWRNXeRFrE",
|
||||
"isSeekable": true,
|
||||
"author": "IU",
|
||||
"length": 195216,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "Hold my hand",
|
||||
"uri": "https://open.spotify.com/track/0KVClXxsZEKkyWRNXeRFrE",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273f1efb467ac4c748630ffd22f",
|
||||
"isrc": "KRA381001561"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/58CBwM98Y356zD5AVGZkZG",
|
||||
"albumName": "My Last Love OST Part.4",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/3HqSLMAZ3g3d5poNaI7GOU",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab6761610000e5eb789f38042e5ef8911fc3826b",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABxQMAEeuCtCDshpDsnYQg7J6h7JWEAAVHaWxtZQAAAAAAAx1YABY1NjFtMHV4aVBqMUpRUG8weEQ0OHFwAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay81NjFtMHV4aVBqMUpRUG8weEQ0OHFwAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwNTU2MQAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzU1alI0bUJxSXNMVUwwUzBTNUZkTGkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczYTM0MDY4Y2IyMTE0Mjc4ZWZiM2ExMTU3AAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "561m0uxiPj1JQPo0xD48qp",
|
||||
"isSeekable": true,
|
||||
"author": "Gilme",
|
||||
"length": 204120,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "내 손을 잡아",
|
||||
"uri": "https://open.spotify.com/track/561m0uxiPj1JQPo0xD48qp",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341405561"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/55jR4mBqIsLUL0S0S5FdLi",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273a34068cb2114278efb3a1157",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABygMACeyCrOueke2VtAAG7Iug7JygAAAAAAADvg8AFjIzVUlWZ0Z5eUVQUU4yUk1jSHUzYzkAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzIzVUlWZ0Z5eUVQUU4yUk1jSHUzYzkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMDQ2ODdjMjljNWI4ZTBlNjc5ODc1NzQ2AQAMS1JBMzQxNDAyNjU3AAdzcG90aWZ5AQBBTUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDEgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDEBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMVVpazNqV0tTSWdnNktISkRrRDBaagEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "23UIVgFyyEPQN2RMcHu3c9",
|
||||
"isSeekable": true,
|
||||
"author": "신유",
|
||||
"length": 245263,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "사랑해",
|
||||
"uri": "https://open.spotify.com/track/23UIVgFyyEPQN2RMcHu3c9",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
|
||||
"isrc": "KRA341402657"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
|
||||
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB1AMAE+yVhO2UhOqzoCDslYTtjIzshJwABuynhOyjvAAAAAAAA0WNABYzNUpyaUt1UU9tWVBlQTdQYWhHd3VLAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zNUpyaUt1UU9tWVBlQTdQYWhHd3VLAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA4NmRkMWZkZTY2NGU2MjM4OWY4ZmJiZgEADEtSQTM0MTQwMzI4OQAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAyIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAyAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzBIMllUNjI5aE56Y1pGTHg0cWNNMU8BADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "35JriKuQOmYPeA7PahGwuK",
|
||||
"isSeekable": true,
|
||||
"author": "진주",
|
||||
"length": 214413,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "아프고 아파서",
|
||||
"uri": "https://open.spotify.com/track/35JriKuQOmYPeA7PahGwuK",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273086dd1fde664e62389f8fbbf",
|
||||
"isrc": "KRA341403289"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/0H2YT629hNzcZFLx4qcM1O",
|
||||
"albumName": "MBC 내손을잡아, Pt. 2 (Original Television Soundtrack) Pt. 2",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABoAMAD1NwcmluZyBNZW1vcmllcwAITi5GbHlpbmcAAAAAAAL6zAAWNndpSDIyenVTa1RzY2s3eGpOdVFzeQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNndpSDIyenVTa1RzY2s3eGpOdVFzeQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNmYThhY2FkMzRlMjUwMWFjYzc0NjRkNTkBAAxLUkEzODE5MDE2ODcAB3Nwb3RpZnkBAA9TcHJpbmcgTWVtb3JpZXMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNFowTzBZbGdabXZIbWNPR1RsR2d1SAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMlptWGV4SUpBRDdQZ0FCcmowcVFSYgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTYxMDAwMGU1ZWJiMGM2NzNjMTcyNWVlY2Q3MDJiOTJjZjIAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "6wiH22zuSkTsck7xjNuQsy",
|
||||
"isSeekable": true,
|
||||
"author": "N.Flying",
|
||||
"length": 195276,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "Spring Memories",
|
||||
"uri": "https://open.spotify.com/track/6wiH22zuSkTsck7xjNuQsy",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273fa8acad34e2501acc7464d59",
|
||||
"isrc": "KRA381901687"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/4Z0O0YlgZmvHmcOGTlGguH",
|
||||
"albumName": "Spring Memories",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/2ZmXexIJAD7PgABrj0qQRb",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab6761610000e5ebb0c673c1725eecd702b92cf2",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABzQMADOq4sOuLpOumsOuLpAAMS2ltIFlvbmcgSmluAAAAAAADJesAFjE0RWVRNXJmemJBOHpxZUMxSEtJTzgAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzE0RWVRNXJmemJBOHpxZUMxSEtJTzgBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzFhZDFhZTFjNzFkNDY5NWI1NDExYmU3AQAMS1JBMzQxNDA0NzEwAAdzcG90aWZ5AQA7TUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDQgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNGQ2Tzd5V0VEOVN1aWdybXYzREpOdAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "14EeQ5rfzbA8zqeC1HKIO8",
|
||||
"isSeekable": true,
|
||||
"author": "Kim Yong Jin",
|
||||
"length": 206315,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "기다린다",
|
||||
"uri": "https://open.spotify.com/track/14EeQ5rfzbA8zqeC1HKIO8",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27331ad1ae1c71d4695b5411be7",
|
||||
"isrc": "KRA341404710"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/4d6O7yWED9Suigrmv3DJNt",
|
||||
"albumName": "MBC 내손을잡아, Pt. 4 (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABygMADOq3uOugpOuzuOuLpAAEU3VraQAAAAAABFy8ABYwNXFtWHR3SlNBemNBM0tHWk9MM2ZyAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8wNXFtWHR3SlNBemNBM0tHWk9MM2ZyAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA1NjE0YTkxOWVhNzM1ZjcxNWI3OGIyOAEADEtSQTM0MTQwMzc0NgAHc3BvdGlmeQEAQE1CQyDrgrTshpDsnYTsnqHslYQgUHQuIDMgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMDRDcXB1QU5qUUxUQ00xM2l6b2hDVwEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvM252ZWhHbVNXdTJqbG5XU0MyRWFjSAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmJhMzE5NmRjYWVkMDljOTc3NDU1ZGEAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "05qmXtwJSAzcA3KGZOL3fr",
|
||||
"isSeekable": true,
|
||||
"author": "Suki",
|
||||
"length": 285884,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "그려본다",
|
||||
"uri": "https://open.spotify.com/track/05qmXtwJSAzcA3KGZOL3fr",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27305614a919ea735f715b78b28",
|
||||
"isrc": "KRA341403746"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/04CqpuANjQLTCM13izohCW",
|
||||
"albumName": "MBC 내손을잡아 Pt. 3 (Original Television Soundtrack) Pt. 3",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAByAMAE+yVhO2UhOqzoCDslYTtjIzshJwABuynhOyjvAAAAAAAA0WNABYzUjVZMzBrR0p4S3J6SUlHU2JCVEdnAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zUjVZMzBrR0p4S3J6SUlHU2JCVEdnAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwMzI4OQAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "3R5Y30kGJxKrzIIGSbBTGg",
|
||||
"isSeekable": true,
|
||||
"author": "진주",
|
||||
"length": 214413,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "아프고 아파서",
|
||||
"uri": "https://open.spotify.com/track/3R5Y30kGJxKrzIIGSbBTGg",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341403289"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB2QMAGOyCrOueke2VtCAtIEluc3RydW1lbnRhbAAG7Iug7JygAAAAAAADvg8AFjZOR2xabmhxOEFySnFRaUtoM3lpNFEAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzZOR2xabmhxOEFySnFRaUtoM3lpNFEBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMDQ2ODdjMjljNWI4ZTBlNjc5ODc1NzQ2AQAMS1JBMzQxNDAyNjYwAAdzcG90aWZ5AQBBTUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDEgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDEBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMVVpazNqV0tTSWdnNktISkRrRDBaagEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "6NGlZnhq8ArJqQiKh3yi4Q",
|
||||
"isSeekable": true,
|
||||
"author": "신유",
|
||||
"length": 245263,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "사랑해 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/6NGlZnhq8ArJqQiKh3yi4Q",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
|
||||
"isrc": "KRA341402660"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
|
||||
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB2wMAIOqwgOyKtCDslYTtlIgg64KgIC0gSW5zdHJ1bWVudGFsAAbshLHruYgAAAAAAAO3vAAWNUE5dkNKYmY2eDNMUWk2VmlZUmRwYwABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNUE5dkNKYmY2eDNMUWk2VmlZUmRwYwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmU4ZmFmYjVkYTdhNjE2YjBjYTg1NjcBAAxLUkEzNDE0MDUxNzEAB3Nwb3RpZnkBADtNQkMg64K07IaQ7J2E7J6h7JWELCBQdC4gNSAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS8yY0hRU1NONzlHNjBsdEUzNGpGQWZsAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
|
||||
"info": {
|
||||
"identifier": "5A9vCJbf6x3LQi6ViYRdpc",
|
||||
"isSeekable": true,
|
||||
"author": "성빈",
|
||||
"length": 243644,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "가슴 아픈 날 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/5A9vCJbf6x3LQi6ViYRdpc",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
|
||||
"isrc": "KRA341405171"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/2cHQSSN79G60ltE34jFAfl",
|
||||
"albumName": "MBC 내손을잡아, Pt. 5 (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABvwMADOq3uOugpOuzuOuLpAAEU3VraQAAAAAABFy8ABYxOWQ2MUZleHViaXU2NXVpUVhKY25xAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8xOWQ2MUZleHViaXU2NXVpUVhKY25xAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwMzc0NgAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzNudmVoR21TV3UyamxuV1NDMkVhY0gBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczZTZiYTMxOTZkY2FlZDA5Yzk3NzQ1NWRhAAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "19d61Fexubiu65uiQXJcnq",
|
||||
"isSeekable": true,
|
||||
"author": "Suki",
|
||||
"length": 285884,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "그려본다",
|
||||
"uri": "https://open.spotify.com/track/19d61Fexubiu65uiQXJcnq",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341403746"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB4wMAIuyVhO2UhOqzoCDslYTtjIzshJwgLSBJbnN0cnVtZW50YWwABuynhOyjvAAAAAAAA0WNABYzYW8wSkxJWDRYdDc5RWg2YXh6dVFCAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zYW8wSkxJWDRYdDc5RWg2YXh6dVFCAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA4NmRkMWZkZTY2NGU2MjM4OWY4ZmJiZgEADEtSQTM0MTQwMzI5MAAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAyIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAyAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzBIMllUNjI5aE56Y1pGTHg0cWNNMU8BADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "3ao0JLIX4Xt79Eh6axzuQB",
|
||||
"isSeekable": true,
|
||||
"author": "진주",
|
||||
"length": 214413,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "아프고 아파서 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/3ao0JLIX4Xt79Eh6axzuQB",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273086dd1fde664e62389f8fbbf",
|
||||
"isrc": "KRA341403290"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/0H2YT629hNzcZFLx4qcM1O",
|
||||
"albumName": "MBC 내손을잡아, Pt. 2 (Original Television Soundtrack) Pt. 2",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB2gMAGeyCrOueke2VtCAtIFBpYW5vIFZlcnNpb24ABuyLoOycoAAAAAAABETbABYxMFFrU05zTklkQ0ZxQUhEOHpINTdPAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8xMFFrU05zTklkQ0ZxQUhEOHpINTdPAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA0Njg3YzI5YzViOGUwZTY3OTg3NTc0NgEADEtSQTM0MTQwMjY1OQAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAxIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAxAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzFVaWszaldLU0lnZzZLSEpEa0QwWmoBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzVhRWt2Q2VkTTNhUzBtdWZWNnFrTksBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzcxMzMxYmYwOWQ0NDY0OWE1ZWM1Njg5AAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "10QkSNsNIdCFqAHD8zH57O",
|
||||
"isSeekable": true,
|
||||
"author": "신유",
|
||||
"length": 279771,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "사랑해 - Piano Version",
|
||||
"uri": "https://open.spotify.com/track/10QkSNsNIdCFqAHD8zH57O",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
|
||||
"isrc": "KRA341402659"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
|
||||
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB2QMAG+q3uOugpOuzuOuLpCAtIEluc3RydW1lbnRhbAAEU3VraQAAAAAABFy8ABYycjhVemduSmZTTDhuWTc4T3NRUXlRAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8ycjhVemduSmZTTDhuWTc4T3NRUXlRAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA1NjE0YTkxOWVhNzM1ZjcxNWI3OGIyOAEADEtSQTM0MTQwMzc0NwAHc3BvdGlmeQEAQE1CQyDrgrTshpDsnYTsnqHslYQgUHQuIDMgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMDRDcXB1QU5qUUxUQ00xM2l6b2hDVwEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvM252ZWhHbVNXdTJqbG5XU0MyRWFjSAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmJhMzE5NmRjYWVkMDljOTc3NDU1ZGEAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "2r8UzgnJfSL8nY78OsQQyQ",
|
||||
"isSeekable": true,
|
||||
"author": "Suki",
|
||||
"length": 285884,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "그려본다 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/2r8UzgnJfSL8nY78OsQQyQ",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27305614a919ea735f715b78b28",
|
||||
"isrc": "KRA341403747"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/04CqpuANjQLTCM13izohCW",
|
||||
"albumName": "MBC 내손을잡아 Pt. 3 (Original Television Soundtrack) Pt. 3",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABvgMACeyCrOueke2VtAAG7Iug7JygAAAAAAADvg8AFjNYUEQxMEhuZ2ZaM0dsOVY3TWhrWGQAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzNYUEQxMEhuZ2ZaM0dsOVY3TWhrWGQBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczY2Y0Zjc0NWE1OGQwZTY5Y2I2ZjY4ZmZlAQAMS1JBMzQxNDA1NTYyAAdzcG90aWZ5AQA1TUJDIOuCtOyGkOydhOyeoeyVhCwgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNzRoQnZFMHM1NGVDRGZxV01EUlFocQEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "3XPD10HngfZ3Gl9V7MhkXd",
|
||||
"isSeekable": true,
|
||||
"author": "신유",
|
||||
"length": 245263,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "사랑해",
|
||||
"uri": "https://open.spotify.com/track/3XPD10HngfZ3Gl9V7MhkXd",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341405562"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB1AMAIOuCtCDshpDsnYQg7J6h7JWEIC0gSW5zdHJ1bWVudGFsAAVHaWxtZQAAAAAAAyG8ABYweXN2Y2RjNE9oM3hkTDFucXpESXVrAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8weXN2Y2RjNE9oM3hkTDFucXpESXVrAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwNTU2NAAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzU1alI0bUJxSXNMVUwwUzBTNUZkTGkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczYTM0MDY4Y2IyMTE0Mjc4ZWZiM2ExMTU3AAAAAAAAAAAAAA==",
|
||||
"info": {
|
||||
"identifier": "0ysvcdc4Oh3xdL1nqzDIuk",
|
||||
"isSeekable": true,
|
||||
"author": "Gilme",
|
||||
"length": 205244,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "내 손을 잡아 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/0ysvcdc4Oh3xdL1nqzDIuk",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341405564"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/55jR4mBqIsLUL0S0S5FdLi",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273a34068cb2114278efb3a1157",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAAB3AMAG+q4sOuLpOumsOuLpCAtIEluc3RydW1lbnRhbAAMS2ltIFlvbmcgSmluAAAAAAADJgUAFjRqWTRabHJMZmI5cTZGRzFoSmRTQncAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzRqWTRabHJMZmI5cTZGRzFoSmRTQncBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzFhZDFhZTFjNzFkNDY5NWI1NDExYmU3AQAMS1JBMzQxNDA0NzExAAdzcG90aWZ5AQA7TUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDQgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNGQ2Tzd5V0VEOVN1aWdybXYzREpOdAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "4jY4ZlrLfb9q6FG1hJdSBw",
|
||||
"isSeekable": true,
|
||||
"author": "Kim Yong Jin",
|
||||
"length": 206341,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "기다린다 - Instrumental",
|
||||
"uri": "https://open.spotify.com/track/4jY4ZlrLfb9q6FG1hJdSBw",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27331ad1ae1c71d4695b5411be7",
|
||||
"isrc": "KRA341404711"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/4d6O7yWED9Suigrmv3DJNt",
|
||||
"albumName": "MBC 내손을잡아, Pt. 4 (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABxwMADOq4sOuLpOumsOuLpAAMS2ltIFlvbmcgSmluAAAAAAADJesAFjFMd1BlMnpyN2hENVEzblNsN3BVR20AAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzFMd1BlMnpyN2hENVEzblNsN3BVR20BAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczY2Y0Zjc0NWE1OGQwZTY5Y2I2ZjY4ZmZlAQAMS1JBMzQxNDA0NzEwAAdzcG90aWZ5AQA1TUJDIOuCtOyGkOydhOyeoeyVhCwgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNzRoQnZFMHM1NGVDRGZxV01EUlFocQEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
|
||||
"info": {
|
||||
"identifier": "1LwPe2zr7hD5Q3nSl7pUGm",
|
||||
"isSeekable": true,
|
||||
"author": "Kim Yong Jin",
|
||||
"length": 206315,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "기다린다",
|
||||
"uri": "https://open.spotify.com/track/1LwPe2zr7hD5Q3nSl7pUGm",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341404710"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABzAMAEeqwgOyKtCDslYTtlIgg64KgAAbshLHruYgAAAAAAAO31gAWNWU3T1d3bHpxYzlmNlE4YjI2YklhdQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNWU3T1d3bHpxYzlmNlE4YjI2YklhdQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmU4ZmFmYjVkYTdhNjE2YjBjYTg1NjcBAAxLUkEzNDE0MDUxNzAAB3Nwb3RpZnkBADtNQkMg64K07IaQ7J2E7J6h7JWELCBQdC4gNSAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS8yY0hRU1NONzlHNjBsdEUzNGpGQWZsAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
|
||||
"info": {
|
||||
"identifier": "5e7OWwlzqc9f6Q8b26bIau",
|
||||
"isSeekable": true,
|
||||
"author": "성빈",
|
||||
"length": 243670,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "가슴 아픈 날",
|
||||
"uri": "https://open.spotify.com/track/5e7OWwlzqc9f6Q8b26bIau",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
|
||||
"isrc": "KRA341405170"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/2cHQSSN79G60ltE34jFAfl",
|
||||
"albumName": "MBC 내손을잡아, Pt. 5 (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
},
|
||||
{
|
||||
"encoded": "QAABxgMAEeqwgOyKtCDslYTtlIgg64KgAAbshLHruYgAAAAAAAO31gAWMjVpUm52MldXTzJ0UXNuenBBWDN6aAABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svMjVpUm52MldXTzJ0UXNuenBBWDN6aAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNjZjRmNzQ1YTU4ZDBlNjljYjZmNjhmZmUBAAxLUkEzNDE0MDU1NjMAB3Nwb3RpZnkBADVNQkMg64K07IaQ7J2E7J6h7JWELCAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS83NGhCdkUwczU0ZUNEZnFXTURSUWhxAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
|
||||
"info": {
|
||||
"identifier": "25iRnv2WWO2tQsnzpAX3zh",
|
||||
"isSeekable": true,
|
||||
"author": "성빈",
|
||||
"length": 243670,
|
||||
"isStream": false,
|
||||
"position": 0,
|
||||
"title": "가슴 아픈 날",
|
||||
"uri": "https://open.spotify.com/track/25iRnv2WWO2tQsnzpAX3zh",
|
||||
"sourceName": "spotify",
|
||||
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
|
||||
"isrc": "KRA341405563"
|
||||
},
|
||||
"pluginInfo": {
|
||||
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
|
||||
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
|
||||
"previewUrl": null,
|
||||
"isPreview": false,
|
||||
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
|
||||
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
|
||||
"isLocal": false
|
||||
},
|
||||
"userData": {}
|
||||
}
|
||||
]
|
||||
76
bot/src/classes/BotClient.ts
Normal file
76
bot/src/classes/BotClient.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
|
||||
import { Config } from "../utils/Config";
|
||||
|
||||
export class BotClient extends Client {
|
||||
public prefix = Config.prefix;
|
||||
public color: ColorResolvable = Config.embedColor;
|
||||
public constructor() {
|
||||
super({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.login(Config.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 핸들러 등록
|
||||
*
|
||||
* 지정한 이벤트가 발생했을때 해당 핸들러를 호출함
|
||||
* * 'func'의 내용은 기본적으로 'client.on'을 따름
|
||||
* * 'extra'를 입력할 경우 추가되어 같이 전달
|
||||
*
|
||||
* @example
|
||||
* client.onEvent('ready', (client, info) => {
|
||||
* Logger.ready(client?.user.username, '봇이 준비되었습니다.', info) // 출력: OOO 봇이 준비되었습니다. 추가 정보
|
||||
* }, ['추가 정보']);
|
||||
*
|
||||
* @param event 이벤트명
|
||||
* @param func 이벤트 핸들러 함수
|
||||
* @param extra 추가로 전달할 목록
|
||||
*/
|
||||
public readonly onEvent = (event: keyof ClientEvents, func: Function, ...extra: any[]) => this.on(event, (...args) => func(...args, ...extra));
|
||||
|
||||
public mkembed(data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
author?: { name: string, iconURL?: string, url?: string };
|
||||
addFields?: EmbedField[];
|
||||
timestamp?: number | Date | undefined | null;
|
||||
footer?: { text: string, iconURL?: string };
|
||||
color?: ColorResolvable;
|
||||
}): EmbedBuilder {
|
||||
if (!data.color) data.color = this.color;
|
||||
const embed = new EmbedBuilder();
|
||||
if (data.title) embed.setTitle(data.title);
|
||||
if (data.description) embed.setDescription(data.description);
|
||||
if (data.url) embed.setURL(data.url);
|
||||
if (data.image) embed.setImage(data.image);
|
||||
if (data.thumbnail) embed.setThumbnail(data.thumbnail);
|
||||
if (data.author) embed.setAuthor({ name: data.author.name, iconURL: data.author.iconURL, url: data.author.url });
|
||||
if (data.addFields) embed.addFields(data.addFields);
|
||||
if (data.timestamp) embed.setTimestamp(data.timestamp);
|
||||
if (data.footer) embed.setFooter({ text: data.footer.text, iconURL: data.footer.iconURL });
|
||||
if (data.color) embed.setColor(data.color);
|
||||
return embed;
|
||||
}
|
||||
|
||||
public msgDelete(message: Message, time: number, customTime?: boolean): void {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const msg = await message.fetch(true).catch(() => undefined);
|
||||
if (msg?.deletable) msg.delete().catch(() => {});
|
||||
} catch {};
|
||||
}, Math.max(100, time * (customTime ? 1 : 6000)));
|
||||
}
|
||||
}
|
||||
278
bot/src/classes/GuildPlayer.ts
Normal file
278
bot/src/classes/GuildPlayer.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Guild, Message, TextChannel } from "discord.js";
|
||||
import { LoadType, Player, Track, TrackEndEvent } from "shoukaku";
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { timeFormat } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
import { GuildType } from "../../db/db";
|
||||
import { DB } from "../utils/Database";
|
||||
import { shuffle } from "../utils/Shuffle";
|
||||
import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
||||
|
||||
const DelayAfterErrMs = 1000 * 5;
|
||||
const idleEndTime = 1000 * 60 * 10;
|
||||
|
||||
type QueueTrack = Track & { userId: string; };
|
||||
|
||||
export class GuildPlayer {
|
||||
private isDead = false;
|
||||
private canRecommend = true;
|
||||
private _GDB: GuildType | undefined;
|
||||
private lastPlayedTrack: QueueTrack | undefined;
|
||||
public queue: QueueTrack[] = [];
|
||||
private errorTimer: NodeJS.Timeout | undefined;
|
||||
private endTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
public player: Player,
|
||||
public voiceChannelId: string,
|
||||
public textChannel: TextChannel,
|
||||
public msg: Message,
|
||||
) {
|
||||
this.player.setGlobalVolume(50);
|
||||
this.player.on("end", async (data: TrackEndEvent) => {
|
||||
if (this.isDead) return;
|
||||
if (data.reason === "replaced") return;
|
||||
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
||||
this.lastPlayedTrack = this.queue.shift();
|
||||
if (this.queue.length > 0) {
|
||||
await this.playNext();
|
||||
} else if (this.isRecommend && this.canRecommend) {
|
||||
await this.autoPlay();
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
});
|
||||
this.player.on("closed", () => {
|
||||
if (this.isDead) return;
|
||||
this.delete();
|
||||
});
|
||||
}
|
||||
|
||||
private get GDB() {
|
||||
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
|
||||
return this._GDB;
|
||||
}
|
||||
public get isPlaying(): boolean {
|
||||
return this.player.track !== null;
|
||||
}
|
||||
public get nowTrack(): QueueTrack | null {
|
||||
return this.queue[0] ?? null;
|
||||
}
|
||||
public get isPaused(): boolean {
|
||||
return this.player.paused;
|
||||
}
|
||||
public get isRecommend(): boolean {
|
||||
return this.GDB?.options.recommend ?? false;
|
||||
}
|
||||
|
||||
public async addTrack(track: Track, userId: string) {
|
||||
this.queue.push({ ...track, userId });
|
||||
if (this.queue.length === 1) {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public async addTracks(tracks: Track[], userId: string) {
|
||||
const first = this.queue.length === 0;
|
||||
for (const track of tracks) this.queue.push({ ...track, userId });
|
||||
if (first) {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public async playNext() {
|
||||
const track = this.queue[0];
|
||||
if (!track) return this.end();
|
||||
await this.player.playTrack({ track: { encoded: track.encoded } });
|
||||
// 재생관련
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public async setPause() {
|
||||
if (this.isPaused) {
|
||||
await this.player.setPaused(false);
|
||||
this.setMsg();
|
||||
} else if (this.isPlaying) {
|
||||
await this.player.setPaused(true);
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.queue = [];
|
||||
this.canRecommend = false;
|
||||
this.player.stopTrack();
|
||||
}
|
||||
|
||||
public skip() {
|
||||
// 곡 스킵했을때
|
||||
this.player.stopTrack();
|
||||
}
|
||||
|
||||
public setShuffle() {
|
||||
let nowQueue = this.queue.slice(1);
|
||||
nowQueue = shuffle(nowQueue);
|
||||
this.queue = [this.queue[0], ...nowQueue];
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public setRecommend() {
|
||||
if (!this.GDB) return;
|
||||
this.GDB.options.recommend = !this.GDB.options.recommend;
|
||||
DB.guild.update(this.GDB);
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public seek(num: number) {
|
||||
if (!this.isPlaying) return;
|
||||
if (!this.nowTrack) return;
|
||||
this.player.seekTo(num);
|
||||
}
|
||||
|
||||
private async autoPlay() {
|
||||
if (!this.lastPlayedTrack) return;
|
||||
const source = this.lastPlayedTrack.info.sourceName;
|
||||
const trackId = this.lastPlayedTrack.info.identifier;
|
||||
let result;
|
||||
if (source === "spotify") {
|
||||
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`);
|
||||
} else {
|
||||
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
|
||||
}
|
||||
let tracks: Track[] = [];
|
||||
if (result?.loadType === LoadType.PLAYLIST) {
|
||||
tracks = result.data.tracks;
|
||||
} else if (result?.loadType === LoadType.SEARCH || Array.isArray(result?.data)) {
|
||||
tracks = Array.isArray(result.data) ? result.data : [];
|
||||
} else if (result?.loadType === LoadType.TRACK) {
|
||||
tracks = [ result.data ];
|
||||
}
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
|
||||
this.addTracks(tracks, "자동재생");
|
||||
}
|
||||
|
||||
public end() {
|
||||
if (this.errorTimer !== undefined) {
|
||||
clearTimeout(this.errorTimer);
|
||||
this.errorTimer = undefined;
|
||||
}
|
||||
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
|
||||
this.endTimer = setTimeout(() => {
|
||||
this.endTimer = undefined;
|
||||
this.delete(true);
|
||||
}, idleEndTime);
|
||||
this.lastPlayedTrack = undefined;
|
||||
this.queue = [];
|
||||
this.canRecommend = true;
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public delete(afterEnd?: boolean) {
|
||||
if (this.isDead) return;
|
||||
if (!afterEnd) this.end();
|
||||
this.isDead = true;
|
||||
this.player.destroy().catch(() => {});
|
||||
lavalinkManager.delPlayer(this.guild.id);
|
||||
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
}
|
||||
|
||||
private async setMsg() {
|
||||
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
|
||||
if (!check) return;
|
||||
this.textChannel = channel;
|
||||
this.msg = msg;
|
||||
if (!this.msg) return;
|
||||
|
||||
// content
|
||||
let content = default_content;
|
||||
let realQueue = this.queue.slice(1);
|
||||
if (realQueue.length > 0) {
|
||||
content = "__**대기열 목록:**__\n";
|
||||
let list: string[] = [];
|
||||
let nowLength = content.length + 50; // 50자 여유두기
|
||||
for (let i=0; i<realQueue.length; i++) {
|
||||
let track = realQueue[i];
|
||||
let text = `${
|
||||
(i+1).toString().padStart(realQueue.length.toString().length, "0")
|
||||
}\\. ${
|
||||
track.info.author.replace(" - Topic","")
|
||||
} - ${
|
||||
track.info.title
|
||||
} [${
|
||||
timeFormat(track.info.length)
|
||||
}] ~ ${
|
||||
track.userId === "자동재생" ? "자동재생" : `<@${track.userId}>`
|
||||
}`;
|
||||
if (nowLength + text.length > 2000) {
|
||||
content += `+ ${realQueue.length-list.length}곡\n`;
|
||||
break;
|
||||
}
|
||||
nowLength += text.length;
|
||||
list.push(text);
|
||||
}
|
||||
content += list.reverse().join("\n");
|
||||
}
|
||||
|
||||
// embed
|
||||
const embed = (this.isPlaying || this.isPaused) && !!this.nowTrack
|
||||
? client.mkembed({
|
||||
title: `**[${timeFormat(this.nowTrack.info.length)}] ${this.nowTrack.info.author.replace(" - Topic","")} - ${this.nowTrack.info.title}**`,
|
||||
description: `노래 요청자: ${this.nowTrack.userId === "자동재생" ? "자동재생" : `<@${this.nowTrack.userId}>`}`,
|
||||
image: this.nowTrack.info.artworkUrl,
|
||||
url: this.nowTrack.info.author.includes(" - Topic") ? this.nowTrack.info.uri?.replace("www.youtube","music.youtube") : this.nowTrack.info.uri,
|
||||
footer: { text: `대기열: ${realQueue.length}개 | 볼륨: ${this.player.volume}${
|
||||
this.isRecommend ? " | 자동재생: 활성화" : ""
|
||||
}${
|
||||
this.isPaused ? " | 노래가 일시중지 되었습니다." : ""
|
||||
}` }
|
||||
})
|
||||
: default_embed(this.guild.id);
|
||||
|
||||
this.msg.edit({
|
||||
content: content,
|
||||
embeds: [ embed ],
|
||||
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
|
||||
components: [ getButtons(
|
||||
this.isPlaying || this.isPaused,
|
||||
this.isPaused,
|
||||
realQueue.length > 1,
|
||||
) ]
|
||||
});
|
||||
}
|
||||
|
||||
public async errMsg(reason: string) {
|
||||
if (!this.msg) return null;
|
||||
await this.msg.edit({
|
||||
content: this.msg.content,
|
||||
embeds: [ client.mkembed({
|
||||
title: "오류발생!!",
|
||||
description: reason,
|
||||
color: "DarkRed",
|
||||
}) ],
|
||||
files: [],
|
||||
components: [ getButtons(
|
||||
false,
|
||||
false,
|
||||
this.queue.length > 1,
|
||||
) ]
|
||||
});
|
||||
this.errorTimer = setTimeout(() => {
|
||||
this.errorTimer = undefined;
|
||||
this.playNext();
|
||||
}, DelayAfterErrMs);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
25
bot/src/classes/Handler.ts
Normal file
25
bot/src/classes/Handler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChatInputCommandInteraction, Collection } from "discord.js";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { Command } from "../types/Command";
|
||||
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
|
||||
|
||||
export class Handler {
|
||||
public commands: Collection<string, Command> = new Collection();
|
||||
public coolDown: Map<string, number> = new Map();
|
||||
|
||||
public constructor() {
|
||||
const commandFiles = readdirSync(COMMANDS_PATH);
|
||||
for (const commandFile of commandFiles) {
|
||||
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
|
||||
this.commands.set(command.metaData.name, command);
|
||||
}
|
||||
}
|
||||
|
||||
public runCommand(interaction: ChatInputCommandInteraction) {
|
||||
const commandName = interaction.commandName;
|
||||
const command = this.commands.get(commandName);
|
||||
|
||||
if (!command) return;
|
||||
if (command.slashRun) command.slashRun(interaction);
|
||||
}
|
||||
}
|
||||
83
bot/src/classes/LavalinkManager.ts
Normal file
83
bot/src/classes/LavalinkManager.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Connectors, LoadType, Shoukaku } from "shoukaku";
|
||||
import { Client } from "discord.js";
|
||||
import { GuildPlayer } from "./GuildPlayer";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { parseLink } from "../utils/music/Url";
|
||||
import { shuffle } from "../utils/Shuffle";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||
|
||||
export class LavalinkManager {
|
||||
public shoukaku: Shoukaku;
|
||||
private players: Map<string, GuildPlayer> = new Map();
|
||||
|
||||
constructor(readonly client: Client) {
|
||||
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
|
||||
name: "tkrmagid-Lavalink-Server",
|
||||
url: `${Config.lavalink.host}:${Config.lavalink.port}`,
|
||||
auth: Config.lavalink.pw,
|
||||
secure: false,
|
||||
}], {
|
||||
moveOnDisconnect: true,
|
||||
reconnectTries: 3,
|
||||
});
|
||||
|
||||
this.shoukaku.on("ready", (name) => {
|
||||
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
|
||||
});
|
||||
this.shoukaku.on("error", (name, err) => {
|
||||
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
getPlayer(guildId: string) {
|
||||
return this.players.get(guildId);
|
||||
}
|
||||
addPlayer(guildId: string, player: GuildPlayer) {
|
||||
this.players.set(guildId, player);
|
||||
}
|
||||
delPlayer(guildId: string) {
|
||||
this.players.delete(guildId);
|
||||
}
|
||||
|
||||
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
|
||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||
if (!node) {
|
||||
this.delPlayer(guildId);
|
||||
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
||||
}
|
||||
player = player ?? this.getPlayer(guildId);
|
||||
if (!player) return;
|
||||
const { isUrl, text, flags } = parseLink(query.trim());
|
||||
let searchText = isUrl ? text : (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
if (flags.has('y')) searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
|
||||
const result = await node.rest.resolve(searchText);
|
||||
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
||||
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
|
||||
// 노래 못찾았을때
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.PLAYLIST) {
|
||||
player.addTracks(
|
||||
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
|
||||
userId
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.TRACK) {
|
||||
player.addTrack(result.data, userId);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.SEARCH) {
|
||||
if (result.data.length === 0) {
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
player.addTrack(result.data[0], userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
bot/src/classes/RedisClient.ts
Normal file
77
bot/src/classes/RedisClient.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
|
||||
export class RedisClient {
|
||||
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||||
public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||||
|
||||
constructor() {
|
||||
this.pub.on("connect", () => {
|
||||
Logger.ready(`[Redis Pub] 연결 완료 (말하는 입)`);
|
||||
});
|
||||
this.sub.on("connect", () => {
|
||||
Logger.ready(`[Redis Sub] 연결 완료 (듣는 귀)`);
|
||||
});
|
||||
|
||||
this.sub.subscribe("site-bot", (err, count) => {
|
||||
if (err) return Logger.error(`[Redis Sub] 구독 실패: ${err.message}`);
|
||||
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
|
||||
});
|
||||
|
||||
this.sub.on("message", async (ch, msg) => {
|
||||
if (ch !== "site-bot") return;
|
||||
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
|
||||
try {
|
||||
const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; };
|
||||
|
||||
if (data.action === "search") {
|
||||
const resultKey = `search:result:${data.requestId}`;
|
||||
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify(results));
|
||||
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.pub.on("error", (err) => {
|
||||
Logger.error(`[Redis Pub] [Error] ${err.message}`);
|
||||
});
|
||||
this.sub.on("error", (err) => {
|
||||
Logger.error(`[Redis Sub] [Error] ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public publishState(event: string, data: any) {
|
||||
const payload = JSON.stringify({
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
...data,
|
||||
});
|
||||
this.pub.publish("bot-site", payload);
|
||||
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
|
||||
}
|
||||
|
||||
public runTest() {
|
||||
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
|
||||
setTimeout(() => {
|
||||
// 1. 봇 -> 사이트(웹) 방향 전송 테스트
|
||||
this.publishState("TRACK_START", {
|
||||
author: "테스트",
|
||||
title: "제목",
|
||||
duration: 196000,
|
||||
});
|
||||
|
||||
// 2. 사이트(웹) -> 봇 방향 수신 테스트 (가짜 명령을 쏴서 스스로 수신하는지 확인)
|
||||
setTimeout(() => {
|
||||
const mockCommand = JSON.stringify({ action: "skip", userId: "12345" });
|
||||
// 테스트를 위해 본인이 site-bot 채널로 발행해 봅니다.
|
||||
this.pub.publish("site-bot", mockCommand);
|
||||
}, 1000);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
134
bot/src/commands/channel.ts
Normal file
134
bot/src/commands/channel.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ApplicationCommandOptionType, ChannelType, ChatInputApplicationCommandData, ChatInputCommandInteraction, EmbedBuilder, Guild } from "discord.js";
|
||||
import { client } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { clearAllMsg } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
import { DB } from "../utils/Database";
|
||||
|
||||
/** channel 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "channel";
|
||||
visible = true;
|
||||
aliases: string[] = [];
|
||||
description: string = "채널 생성 또는 연결";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
options: [
|
||||
{
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
name: "make",
|
||||
description: "채널 만들기",
|
||||
},
|
||||
{
|
||||
type: ApplicationCommandOptionType.Subcommand,
|
||||
name: "register",
|
||||
description: "기존 채팅채널 등록",
|
||||
options: [
|
||||
{
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
name: "channel",
|
||||
description: "등록할 채널 (선택)",
|
||||
channel_types: [ChannelType.GuildText],
|
||||
},
|
||||
{
|
||||
type: ApplicationCommandOptionType.String,
|
||||
name: "channel_id",
|
||||
description: "채널 ID 또는 #멘션 (선택)",
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
const cmd = interaction.options.getSubcommand();
|
||||
if (cmd === "make") {
|
||||
await interaction.editReply({ embeds: [ await this.channelCreate(interaction.guild) ] });
|
||||
return;
|
||||
}
|
||||
if (cmd === "register") {
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const channelId = interaction.options.getString("channel_id");
|
||||
await interaction.editReply({ embeds: [ await channelRegister(interaction.guild, channel?.id || channelId) ] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async channelCreate(guild: Guild | null): Promise<EmbedBuilder> {
|
||||
if (!guild) return client.mkembed({
|
||||
title: "guild를 가져올수 없습니다.",
|
||||
color: "DarkRed",
|
||||
});
|
||||
const channel = await guild.channels.create({
|
||||
name: "MUSIC채널",
|
||||
type: ChannelType.GuildText,
|
||||
topic: "채팅에 노래제목 또는 주소를 입력해 사용하세요.",
|
||||
}).catch(() => null);
|
||||
if (!channel) return client.mkembed({
|
||||
title: "채널 생성 오류",
|
||||
color: "DarkRed"
|
||||
});
|
||||
return channelRegister(guild, channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelRegister(guild: Guild | null, channelId: string | null): Promise<EmbedBuilder> {
|
||||
if (!guild) return client.mkembed({
|
||||
title: "guild를 가져올수 없습니다.",
|
||||
color: "DarkRed",
|
||||
});
|
||||
if (!channelId) return client.mkembed({
|
||||
title: "채널 아이디 가져오기 오류",
|
||||
color: "DarkRed",
|
||||
});
|
||||
const channel = guild.channels.cache.get(channelId.replace(/\<|\#|\!|\>/g,"").trim());
|
||||
if (!channel?.id) return client.mkembed({
|
||||
title: `${channelId} 채널 가져오기 오류`,
|
||||
color: "DarkRed",
|
||||
});
|
||||
if (channel.type !== ChannelType.GuildText) return client.mkembed({
|
||||
title: `<#${channelId}> 채팅 채널이 아닙니다.`,
|
||||
color: "DarkRed",
|
||||
});
|
||||
await clearAllMsg(channel);
|
||||
const msg = await channel.send({
|
||||
content: default_content,
|
||||
embeds: [ default_embed(guild.id) ],
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
if (!msg) return client.mkembed({
|
||||
title: "메세지 생성 오류",
|
||||
color: "DarkRed"
|
||||
});
|
||||
if (!DB.guild.get(guild.id)) DB.guild.set({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
channel_id: "",
|
||||
msg_id: "",
|
||||
options: { recommend: false }
|
||||
});
|
||||
const gdb = DB.guild.get(guild.id);
|
||||
if (!gdb) return client.mkembed({
|
||||
title: "DB 생성 오류",
|
||||
color: "DarkRed",
|
||||
});
|
||||
if (!DB.guild.update({
|
||||
...gdb,
|
||||
channel_id: channel.id,
|
||||
msg_id: msg.id,
|
||||
})) return client.mkembed({
|
||||
title: "DB 등록 오류",
|
||||
color: "DarkRed",
|
||||
});
|
||||
return client.mkembed({
|
||||
title: "채널 생성/등록 완료",
|
||||
description: `채널: <#${channel.id}>`,
|
||||
});
|
||||
}
|
||||
27
bot/src/commands/example.ts
Normal file
27
bot/src/commands/example.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { client } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { Message, ChatInputApplicationCommandData, ChatInputCommandInteraction, ChannelType } from "discord.js";
|
||||
|
||||
/** example 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "example";
|
||||
visible = false;
|
||||
aliases: string[] = ["예시"];
|
||||
description: string = "예시 명령어";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
await interaction.editReply({ embeds: [ client.mkembed({
|
||||
title: "예시 명령어",
|
||||
}) ] });
|
||||
}
|
||||
async messageRun(message: Message) {
|
||||
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
|
||||
}
|
||||
}
|
||||
105
bot/src/commands/help.ts
Normal file
105
bot/src/commands/help.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { client, handler } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { CacheType, Message, ActionRowBuilder, EmbedBuilder, ChatInputApplicationCommandData, StringSelectMenuBuilder, StringSelectMenuInteraction, ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction } from "discord.js";
|
||||
|
||||
/** help 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "help";
|
||||
visible = true;
|
||||
aliases: string[] = ["도움말"];
|
||||
description: string = "명령어 확인";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
await interaction.editReply(this.getHelp());
|
||||
}
|
||||
async messageRun(message: Message) {
|
||||
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||
return await message.channel.send(this.getHelp()).then(m => client.msgDelete(m, 5));
|
||||
}
|
||||
async menuRun(interaction: StringSelectMenuInteraction<CacheType>, args: string[]) {
|
||||
const command = handler.commands.get(args[0]);
|
||||
var embed = client.mkembed({});
|
||||
var embed2: EmbedBuilder | undefined = undefined;
|
||||
if (command) {
|
||||
embed.setTitle(`\` /${args[0]} 도움말 \``)
|
||||
.setDescription(`이름: ${args[0]}\n설명: ${command.description}`);
|
||||
embed2 = helpData(command.metaData.name, command.metaData);
|
||||
} else {
|
||||
embed.setTitle(`\` ${args[0]} 도움말 \``)
|
||||
.setDescription(`명령어를 찾을수 없습니다.`)
|
||||
.setFooter({ text: `도움말: /help` })
|
||||
.setColor('DarkRed');
|
||||
}
|
||||
if (embed2) {
|
||||
await interaction.editReply({ embeds: [ embed, embed2 ] });
|
||||
return;
|
||||
}
|
||||
await interaction.editReply({ embeds: [ embed ] });
|
||||
}
|
||||
|
||||
getHelp(): { embeds: EmbedBuilder[], components: ActionRowBuilder<StringSelectMenuBuilder>[] } {
|
||||
const slashcmdembed = client.mkembed({
|
||||
title: `\` slash (/) 도움말 \``,
|
||||
description: `명령어\n명령어 설명`
|
||||
});
|
||||
const msgcmdembed = client.mkembed({
|
||||
title: `\` 기본 (${client.prefix}) 도움말 \``,
|
||||
description: `명령어 [같은 명령어]\n명령어 설명`,
|
||||
footer: { text: `PREFIX: ${client.prefix}` }
|
||||
});
|
||||
let cmdlist: { label: string, description: string, value: string }[] = [];
|
||||
handler.commands.forEach((cmd) => {
|
||||
if (cmd.slashRun && cmd.visible) {
|
||||
cmdlist.push({ label: `/${cmd.name}`, description: `${cmd.description}`, value: `${cmd.name}` });
|
||||
slashcmdembed.addFields([{ name: `**/${cmd.name}**`, value: `${cmd.description}`, inline: true }]);
|
||||
}
|
||||
});
|
||||
handler.commands.forEach((cmd) => {
|
||||
if (cmd.messageRun && cmd.visible) {
|
||||
msgcmdembed.addFields([{ name: `**${client.prefix}${cmd.name}${cmd.aliases ? ` [ ${cmd.aliases} ]` : ""}**`, value: `${cmd.description}`, inline: true }]);
|
||||
}
|
||||
});
|
||||
const rowhelp = client.mkembed({
|
||||
title: '\` 명령어 상세보기 \`',
|
||||
description: `명령어의 자세한 내용은\n아래의 선택박스에서 선택해\n확인할수있습니다.`,
|
||||
footer: { text: '여러번 가능' }
|
||||
});
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder()
|
||||
.setCustomId('help')
|
||||
.setPlaceholder('명령어를 선택해주세요.')
|
||||
.addOptions(cmdlist)
|
||||
);
|
||||
return { embeds: [ slashcmdembed, msgcmdembed, rowhelp ], components: [ row ] };
|
||||
}
|
||||
}
|
||||
|
||||
function helpData(name: string, metadata: ChatInputApplicationCommandData): EmbedBuilder | undefined {
|
||||
var text = "";
|
||||
metadata.options?.forEach((opt) => {
|
||||
text += `/${name} ${opt.name}`;
|
||||
if (opt.type === ApplicationCommandOptionType.Subcommand && opt.options) {
|
||||
if (opt.options.length > 1) {
|
||||
text = "";
|
||||
opt.options.forEach((opt2) => {
|
||||
text += `/${name} ${opt.name} [${opt2.type}] : ${opt.description}\n`;
|
||||
});
|
||||
} else {
|
||||
text += ` [${opt.options[0].type}] : ${opt.description}\n`;
|
||||
}
|
||||
} else {
|
||||
text += ` : ${opt.description}\n`;
|
||||
}
|
||||
});
|
||||
if (!text || text.length == 0) return undefined;
|
||||
return client.mkembed({
|
||||
title: `\` ${name} 명령어 \``,
|
||||
description: text,
|
||||
});
|
||||
}
|
||||
93
bot/src/commands/join.ts
Normal file
93
bot/src/commands/join.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Message, ChatInputApplicationCommandData, Guild, ChatInputCommandInteraction, EmbedBuilder, ApplicationCommandOptionType, ChannelType } from "discord.js";
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { GuildPlayer } from "../classes/GuildPlayer";
|
||||
import { getTextChannelAndMsg } from "../utils/music/Channel";
|
||||
|
||||
/** join 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "join";
|
||||
visible = true;
|
||||
aliases: string[] = [];
|
||||
description: string = "음성채널 참가";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
options: [
|
||||
{
|
||||
type: ApplicationCommandOptionType.Channel,
|
||||
name: "channel",
|
||||
description: "등록할 채널 (선택)",
|
||||
channel_types: [ChannelType.GuildVoice],
|
||||
},
|
||||
{
|
||||
type: ApplicationCommandOptionType.String,
|
||||
name: "channel_id",
|
||||
description: "채널 ID 또는 #멘션 (선택)",
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const channelId = interaction.options.getString("channel_id");
|
||||
await interaction.editReply({ embeds: [ (await channelJoin(interaction.guild, channel?.id || channelId)).embed ] });
|
||||
return;
|
||||
}
|
||||
async messageRun(message: Message) {
|
||||
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||
return;
|
||||
// return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelJoin(guild: Guild | null, voiceChannelId: string | null): Promise<{
|
||||
embed: EmbedBuilder;
|
||||
player?: GuildPlayer;
|
||||
}> {
|
||||
if (!guild) return { embed: client.mkembed({
|
||||
title: "guild를 가져올수 없습니다.",
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
const { channel, msg, reason } = await getTextChannelAndMsg(guild);
|
||||
if (reason || !channel || !msg) return { embed: client.mkembed({
|
||||
title: reason ?? "오류발생",
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
|
||||
if (!voiceChannelId) return { embed: client.mkembed({
|
||||
title: "채널 아이디 가져오기 오류",
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
const voiceChannel = guild.channels.cache.get(voiceChannelId.replace(/\<|\#|\!|\>/g,"").trim());
|
||||
if (!voiceChannel?.id) return { embed: client.mkembed({
|
||||
title: `${voiceChannelId} 채널 가져오기 오류`,
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
if (voiceChannel.type !== ChannelType.GuildVoice) return { embed: client.mkembed({
|
||||
title: `<#${voiceChannelId}> 음성 채널이 아닙니다.`,
|
||||
color: "DarkRed",
|
||||
}) };
|
||||
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player };
|
||||
player = new GuildPlayer(
|
||||
guild,
|
||||
await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
guildId: guild.id,
|
||||
channelId: voiceChannel.id,
|
||||
shardId: guild.shardId,
|
||||
deaf: true,
|
||||
mute: false,
|
||||
}),
|
||||
voiceChannel.id,
|
||||
channel,
|
||||
msg,
|
||||
);
|
||||
lavalinkManager.addPlayer(guild.id, player);
|
||||
return { embed: client.mkembed({
|
||||
title: `<#${voiceChannelId}> 참가 완료`,
|
||||
}), player };
|
||||
}
|
||||
49
bot/src/commands/ping.ts
Normal file
49
bot/src/commands/ping.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { client } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { Message, ChatInputApplicationCommandData, ChatInputCommandInteraction, ChannelType } from "discord.js";
|
||||
|
||||
/** ping 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "ping";
|
||||
visible = true;
|
||||
aliases: string[] = ["핑"];
|
||||
description: string = "기본 명령어";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
const msg = await interaction.editReply({ embeds: [ client.mkembed({
|
||||
title: "핑...",
|
||||
description: "계산중...",
|
||||
}) ] });
|
||||
if (!(msg instanceof Message)) {
|
||||
await interaction.editReply({ embeds: [ client.mkembed({
|
||||
title: "오류",
|
||||
description: `메시지 타입 불일치`,
|
||||
color: "DarkRed",
|
||||
}) ] });
|
||||
return;
|
||||
}
|
||||
const ping = msg.createdTimestamp - interaction.createdTimestamp;
|
||||
await interaction.editReply({ embeds: [ client.mkembed({
|
||||
title: "퐁!!",
|
||||
description: `${ping}ms`,
|
||||
}) ] });
|
||||
}
|
||||
async messageRun(message: Message) {
|
||||
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||
const msg = await message.channel.send({ embeds: [ client.mkembed({
|
||||
title: "핑...",
|
||||
description: "계산중...",
|
||||
}) ] });
|
||||
const ping = msg.createdTimestamp - message.createdTimestamp;
|
||||
return await msg.edit({ embeds: [ client.mkembed({
|
||||
title: "퐁!!",
|
||||
description: `${ping}ms`,
|
||||
}) ] }).then(m => client.msgDelete(m, 1));
|
||||
}
|
||||
}
|
||||
83
bot/src/commands/seek.ts
Normal file
83
bot/src/commands/seek.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { Command } from "../types/Command";
|
||||
import { Message, ChatInputApplicationCommandData, ChannelType, ChatInputCommandInteraction, ApplicationCommandOptionType, Guild } from "discord.js";
|
||||
|
||||
const formatTime = (sec: number): string => {
|
||||
const s = Math.max(0, Math.floor(sec));
|
||||
return [
|
||||
Math.floor(s / 3600),
|
||||
Math.floor((s % 3600) / 60),
|
||||
s % 60,
|
||||
].map(v => v.toString().padStart(2,"0")).join(":");
|
||||
}
|
||||
|
||||
/** seek 명령어 */
|
||||
export default class implements Command {
|
||||
/** 해당 명령어 설명 */
|
||||
name = "seek";
|
||||
visible = true;
|
||||
aliases: string[] = [];
|
||||
description: string = "특정시간부터 재생";
|
||||
metaData: ChatInputApplicationCommandData = {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
options: [
|
||||
{
|
||||
type: ApplicationCommandOptionType.Integer,
|
||||
name: "hour",
|
||||
description: "시간",
|
||||
min_value: 0,
|
||||
},
|
||||
{
|
||||
type: ApplicationCommandOptionType.Integer,
|
||||
name: "min",
|
||||
description: "분",
|
||||
min_value: 0,
|
||||
},
|
||||
{
|
||||
type: ApplicationCommandOptionType.Integer,
|
||||
name: "sec",
|
||||
description: "초",
|
||||
min_value: 0,
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
/** 실행되는 부분 */
|
||||
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||
const hour = interaction.options.getInteger("hour");
|
||||
const min = interaction.options.getInteger("min");
|
||||
const sec = interaction.options.getInteger("sec");
|
||||
await interaction.editReply({ embeds: [ await this.playSeek(interaction.guild, hour, min, sec) ] });
|
||||
}
|
||||
async messageRun(message: Message) {
|
||||
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
|
||||
}
|
||||
|
||||
async playSeek(guild: Guild | null, hour: number | null, min: number | null, sec: number | null) {
|
||||
if (!guild) return client.mkembed({
|
||||
title: "guild를 가져올수 없습니다.",
|
||||
color: "DarkRed",
|
||||
});
|
||||
const seek = ((hour ?? 0)*3600 + (min ?? 0)*60 + (sec ?? 0))*1000;
|
||||
const player = lavalinkManager.getPlayer(guild.id);
|
||||
if (!player) return client.mkembed({
|
||||
title: "player를 가져올수 없습니다.",
|
||||
color: "DarkRed",
|
||||
});
|
||||
if (!player.isPlaying || !player.nowTrack) return client.mkembed({
|
||||
title: "현재 노래가 재생되지 않았습니다.",
|
||||
color: "DarkRed",
|
||||
});
|
||||
if (player.nowTrack.info.length <= seek) return client.mkembed({
|
||||
title: "노래 총시간이 더 작습니다.",
|
||||
description: `총시간: ${formatTime(player.nowTrack.info.length)}\n설정시간: ${formatTime(seek)}`,
|
||||
color: "DarkRed",
|
||||
});
|
||||
player.seek(seek);
|
||||
return client.mkembed({
|
||||
title: "재생 설정 성공",
|
||||
});
|
||||
}
|
||||
}
|
||||
40
bot/src/events/clientReady.ts
Normal file
40
bot/src/events/clientReady.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Config } from "../utils/Config";
|
||||
import { client, handler, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { DB } from "../utils/Database";
|
||||
import { channelRegister } from "../commands/channel";
|
||||
|
||||
export const clientReady = async () => {
|
||||
if (!client.user) return;
|
||||
Logger.ready(`봇 활성화! ${client.user.username}`);
|
||||
|
||||
client.user.setActivity({
|
||||
name: "MUSIC 봇",
|
||||
});
|
||||
|
||||
reloadMsg();
|
||||
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
|
||||
|
||||
if (!Config.dev) return;
|
||||
try {
|
||||
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
||||
await client.application?.commands.set(body, Config.guildId);
|
||||
Logger.ready(`DEV 길드(${Config.guildId}) 슬래시 등록: ${body.length}개`);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadMsg() {
|
||||
const guilds = DB.guild.all();
|
||||
if (guilds.length === 0) return;
|
||||
for (const gdb of guilds.filter(g => g.channel_id)) {
|
||||
Logger.info(`${gdb.name} : 채널 재연동`);
|
||||
const guild = client.guilds.cache.get(gdb.id) ?? null;
|
||||
const embed = await channelRegister(guild, gdb.channel_id);
|
||||
Logger.info(`${guild?.name ?? gdb.name} : ${embed.data.title ?? "오류"}`);
|
||||
}
|
||||
}
|
||||
8
bot/src/events/guildCreate.ts
Normal file
8
bot/src/events/guildCreate.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { client, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const guildCreate = async () => {
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
8
bot/src/events/guildDelete.ts
Normal file
8
bot/src/events/guildDelete.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { client, Redis } from "../index";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const guildDelete = async () => {
|
||||
const guildIds = client.guilds.cache.map(guild => guild.id);
|
||||
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
|
||||
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
|
||||
}
|
||||
13
bot/src/events/index.ts
Normal file
13
bot/src/events/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clientReady } from "./clientReady";
|
||||
import { guildCreate } from "./guildCreate";
|
||||
import { interactionCreate } from "./interactionCreate";
|
||||
import { messageCreate } from "./messageCreate";
|
||||
// import { voiceStateUpdate } from "./voiceStateUpdate";
|
||||
|
||||
export const onEvents = {
|
||||
clientReady,
|
||||
guildCreate,
|
||||
interactionCreate,
|
||||
messageCreate,
|
||||
// voiceStateUpdate,
|
||||
}
|
||||
36
bot/src/events/interactionCreate.ts
Normal file
36
bot/src/events/interactionCreate.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Interaction, MessageFlags } from "discord.js";
|
||||
import { handler } from "../index";
|
||||
import { buttonInteraction } from "../utils/music/Button";
|
||||
|
||||
export const interactionCreate = async (interaction: Interaction) => {
|
||||
if (interaction.isStringSelectMenu()) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
const commandName = interaction.customId;
|
||||
const args = interaction.values;
|
||||
const command = handler.commands.get(commandName);
|
||||
if (command?.menuRun) return await command.menuRun(interaction, args);
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
const args = interaction.customId.split("-");
|
||||
if (!args || args.length === 0) return;
|
||||
|
||||
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
|
||||
const key = args.shift();
|
||||
if (!key) return;
|
||||
const command = handler.commands.get(key);
|
||||
if (command?.buttonRun) return command.buttonRun(interaction, args);
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
/**
|
||||
* 명령어 친사람만 보이게 설정
|
||||
* flags: MessageFlags.Ephemeral
|
||||
*/
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
handler.runCommand(interaction);
|
||||
}
|
||||
83
bot/src/events/messageCreate.ts
Normal file
83
bot/src/events/messageCreate.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ChannelType, Message } from "discord.js";
|
||||
import { client, handler, lavalinkManager } from "../index";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { DB } from "../utils/Database";
|
||||
import { getVoiceChannel } from "../utils/music/Channel";
|
||||
import { channelJoin } from "../commands/join";
|
||||
|
||||
const cmdErr = (message: Message, commandName: string | undefined | null): void => {
|
||||
if (!commandName) return;
|
||||
if (message.channel.type !== ChannelType.GuildText) return;
|
||||
message.channel.send({ embeds: [ client.mkembed({
|
||||
description: `\` ${commandName} \` 이라는 명령어를 찾을 수 없습니다.`,
|
||||
footer: { text: `${Config.prefix}help를 입력해 명령어를 확인해주세요.` },
|
||||
color: "DarkRed",
|
||||
}) ] }).then(m => client.msgDelete(m, 1));
|
||||
}
|
||||
|
||||
export const messageCreate = async (message: Message): Promise<void> => {
|
||||
if (message.author.bot || message.channel.type === ChannelType.DM) return;
|
||||
if (!message.content.startsWith(client.prefix)) {
|
||||
handleMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = message.content.slice(client.prefix.length).trim();
|
||||
const args = content.split(/ +/g);
|
||||
const commandName = args.shift()?.toLocaleLowerCase() || "";
|
||||
const command = handler.commands.get(commandName) || handler.commands.find((cmd) => cmd.aliases.includes(commandName));
|
||||
try {
|
||||
if (!command || !command.messageRun) {
|
||||
if (!commandName || commandName.replace(/\;| +/g,"").length === 0) return;
|
||||
cmdErr(message, commandName);
|
||||
return client.msgDelete(message, 0, true);
|
||||
}
|
||||
command.messageRun(message, args);
|
||||
client.msgDelete(message, 0, true);
|
||||
} catch(err: any) {
|
||||
if (Config.debug) Logger.error(err);
|
||||
cmdErr(message, commandName);
|
||||
return client.msgDelete(message, 0, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(message: Message): Promise<void> {
|
||||
if (!message.guild?.id) return;
|
||||
if (!message.member?.user?.id) return;
|
||||
if (message.channel.type !== ChannelType.GuildText) return;
|
||||
const gdb = DB.guild.get(message.guild.id);
|
||||
if (!gdb?.channel_id) return;
|
||||
if (gdb.channel_id !== message.channel.id) return;
|
||||
let player = lavalinkManager.getPlayer(message.guild.id);
|
||||
const voiceChannel = getVoiceChannel(message.member);
|
||||
if (!player) {
|
||||
if (!voiceChannel) {
|
||||
message.channel.send({ embeds: [ client.mkembed({
|
||||
author: {
|
||||
name: message.member.nickname ?? message.member.user.displayName,
|
||||
iconURL: message.member.displayAvatarURL({}) ?? undefined,
|
||||
},
|
||||
title: "음성채널을 찾을수 없습니다.",
|
||||
description: "음성채널에 들어가서 사용해주세요.",
|
||||
color: "DarkRed",
|
||||
}) ] }).then(m => client.msgDelete(m, 1));
|
||||
return client.msgDelete(message, 100, true);
|
||||
}
|
||||
player = (await channelJoin(message.guild, voiceChannel.id)).player;
|
||||
}
|
||||
if (!player) {
|
||||
message.channel.send({ embeds: [ client.mkembed({
|
||||
author: {
|
||||
name: message.member.nickname ?? message.member.user.displayName,
|
||||
iconURL: message.member.displayAvatarURL({}) ?? undefined,
|
||||
},
|
||||
title: "세션을 찾을수 없습니다.",
|
||||
description: "다시 시도해주세요.",
|
||||
color: "DarkRed",
|
||||
}) ] }).then(m => client.msgDelete(m, 1));
|
||||
return client.msgDelete(message, 100, true);
|
||||
}
|
||||
lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player);
|
||||
return client.msgDelete(message, 100, true);
|
||||
}
|
||||
5
bot/src/events/voiceStateUpdate.ts
Normal file
5
bot/src/events/voiceStateUpdate.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VoiceState } from "discord.js";
|
||||
import { client } from "../index";
|
||||
|
||||
export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
|
||||
}
|
||||
16
bot/src/index.ts
Normal file
16
bot/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BotClient } from "./classes/BotClient";
|
||||
import { LavalinkManager } from "./classes/LavalinkManager";
|
||||
import { Handler } from "./classes/Handler";
|
||||
import { onEvents } from "./events";
|
||||
import { RedisClient } from "./classes/RedisClient";
|
||||
|
||||
export const client = new BotClient();
|
||||
export const lavalinkManager = new LavalinkManager(client);
|
||||
export const handler = new Handler();
|
||||
export const Redis = new RedisClient();
|
||||
|
||||
for (const eventName of Object.keys(onEvents) as (keyof typeof onEvents)[]) {
|
||||
client.onEvent(eventName, onEvents[eventName]);
|
||||
}
|
||||
|
||||
client.start();
|
||||
22
bot/src/types/Command.d.ts
vendored
Normal file
22
bot/src/types/Command.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
export interface Command {
|
||||
/** 메세지 이름 */
|
||||
name: string;
|
||||
/** help 목록 노출 여부 */
|
||||
visible: boolean;
|
||||
/** 메세지 기반 별칭 */
|
||||
aliases: string[];
|
||||
/** 간단 설명 */
|
||||
description: string;
|
||||
/**
|
||||
* 등록 메타: JSON 변환된 바디
|
||||
* (빌드 시 toJSON()해서 REST 등록에 사용)
|
||||
*/
|
||||
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
|
||||
|
||||
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>;
|
||||
messageRun?: (message: Message, args: string[]) => Promise<void>;
|
||||
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
|
||||
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;
|
||||
}
|
||||
7
bot/src/types/Track.d.ts
vendored
Normal file
7
bot/src/types/Track.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SongItem {
|
||||
title: string;
|
||||
artist: string;
|
||||
videoId: string;
|
||||
thumbnail: string; // 썸네일 URL
|
||||
duration: number; // 재생시간 (ms 단위)
|
||||
}
|
||||
42
bot/src/types/Youtube_Cookie.ts
Normal file
42
bot/src/types/Youtube_Cookie.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export enum Cookies {
|
||||
/** 세션, 재생 추적, 봇 감지 */
|
||||
YSC = "YSC",
|
||||
/** 세션 ID, 로그인 상태 인증 */
|
||||
SID = "SID",
|
||||
/** 로그인 세션 서명용 */
|
||||
HSID = "HSID",
|
||||
/** 계정 관련 보조인증 */
|
||||
SSID = "SSID",
|
||||
/** 유튜브 로그인 정보 */
|
||||
LOGIN_INFO = "LOGIN_INFO",
|
||||
/** 보안된 인증 토큰 (YouTube용) */
|
||||
SAPISID = "SAPISID",
|
||||
/** API 인증용 쿠키 */
|
||||
APISID = "APISID",
|
||||
/** 유튜브 요청 시 인증 */
|
||||
Secure1PAPISID = "__Secure-1PAPISID",
|
||||
/** 유튜브 요청 시 인증 (써드파티) */
|
||||
Secure3PAPISID = "__Secure-3PAPISID",
|
||||
/** 광고 및 인증 관련 */
|
||||
Secure1PSID = "__Secure-1PSID",
|
||||
/** 광고 및 인증 관련 (써드파티) */
|
||||
Secure3PSID = "__Secure-3PSID",
|
||||
/** 무결성 검증, 세션 탈취 방지 쿠키 */
|
||||
Secure1PSIDCC = "__Secure-1PSIDCC",
|
||||
/** 무결성 검증, 세션 탈취 방지 쿠키 (써드파티) */
|
||||
Secure3PSIDCC = "__Secure-3PSIDCC",
|
||||
/** 인증 세션 타임스탬프 쿠키 */
|
||||
Secure1PSIDTS = "__Secure-1PSIDTS",
|
||||
/** 인증 세션 타임스탬프 쿠키 (써드파티) */
|
||||
Secure3PSIDTS = "__Secure-3PSIDTS",
|
||||
/** 쿠키 위조 방지 (보안 강화) */
|
||||
SIDCC = "SIDCC",
|
||||
/** 유튜브 설정 정보 */
|
||||
VISITOR_INFO1_LIVE = "VISITOR_INFO1_LIVE",
|
||||
/** 선호 설정 저장 (VISITOR_INFO1_LIVE 같이쓰면 좋음) */
|
||||
PREF = "PREF",
|
||||
/** 개인정보/쿠키 사용에 대한 동의여부 */
|
||||
CONSENT = "CONSENT",
|
||||
/** 보안/동의 상태 저장 */
|
||||
SOCS = "SOCS",
|
||||
};
|
||||
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;
|
||||
}
|
||||
26
bot/tsconfig.json
Normal file
26
bot/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "commonjs",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
"noImplicitAny": true, // any타입 금지 여부
|
||||
"strictNullChecks": true, // null, undefined 타입에 이상한 짓 할시 에러내기
|
||||
"strictFunctionTypes": true, // 함수파라미터 타입체크 강하게
|
||||
"strictPropertyInitialization": true, // class constructor 작성시 타입체크 강하게
|
||||
"noImplicitThis": true, // this 키워드가 any 타입일 경우 에러내기
|
||||
|
||||
"noUnusedLocals": true, // 쓰지않는 지역변수 있으면 에러내기
|
||||
"noUnusedParameters": true, // 쓰지않는 파라미터 있으면 에러내기
|
||||
"noImplicitReturns": true, // 함수에서 return 빼먹으면 에러내기
|
||||
"noFallthroughCasesInSwitch": true // switch문 이상하면 에러내기
|
||||
},
|
||||
"include": [ "src" ]
|
||||
}
|
||||
41
page/.gitignore
vendored
Normal file
41
page/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
page/AGENTS.md
Normal file
5
page/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
page/CLAUDE.md
Normal file
1
page/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
page/README.md
Normal file
36
page/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
page/eslint.config.mjs
Normal file
18
page/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
11
page/next.config.ts
Normal file
11
page/next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"192.168.10.13",
|
||||
"localhost",
|
||||
]
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6949
page/package-lock.json
generated
Normal file
6949
page/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
page/package.json
Normal file
30
page/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "music_bot_v2_site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"colors": "^1.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
page/postcss.config.mjs
Normal file
7
page/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
34
page/src/app/api/auth/[...nextauth]/route.ts
Normal file
34
page/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import DiscordProvider from "next-auth/providers/discord";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
DiscordProvider({
|
||||
clientId: process.env.DISCORD_CLIENT_ID as string,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
|
||||
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
|
||||
authorization: { params: { scope: "identify email guilds" } },
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||
async jwt({ token, account }) {
|
||||
if (account) {
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: any) {
|
||||
session.accessToken = token.accessToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
// App Router 환경에서는 GET과 POST 메서드를 둘 다 내보내야 합니다.
|
||||
export { handler as GET, handler as POST };
|
||||
43
page/src/app/api/search/route.ts
Normal file
43
page/src/app/api/search/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/app/api/search/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 1. 검색어(query) 가져오기
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q");
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `search:result:${requestId}`;
|
||||
|
||||
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
action: "search",
|
||||
query: query,
|
||||
requestId: requestId,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
||||
for (let i=0; i<10; i++) {
|
||||
// 0.5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
console.log(resultData);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
}
|
||||
34
page/src/app/api/servers/route.ts
Normal file
34
page/src/app/api/servers/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions) as any;
|
||||
|
||||
if (!session || !session.accessToken) {
|
||||
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
|
||||
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
const userGuilds = await userGuildsRes.json();
|
||||
|
||||
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
|
||||
const botGuildsData = await Redis.get("bot-guilds");
|
||||
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
|
||||
|
||||
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
|
||||
const filteredGuilds = userGuilds.filter((guild: any) =>
|
||||
botGuildIds.includes(guild.id)
|
||||
);
|
||||
|
||||
return NextResponse.json(filteredGuilds);
|
||||
} catch (error) {
|
||||
console.error("서버 필터링 에러:", error);
|
||||
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
page/src/app/globals.css
Normal file
30
page/src/app/globals.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */
|
||||
@theme {
|
||||
--color-background: #171717; /* neutral-900 */
|
||||
--color-foreground: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 스크롤바 예쁘게 숨기기 (선택 사항) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
23
page/src/app/layout.tsx
Normal file
23
page/src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Providers from "@/components/Providers"; // 🌟 방금 만든 Provider 불러오기
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MusicBot Web",
|
||||
description: "디스코드 음악 봇 웹 대시보드",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>
|
||||
{/* 🌟 앱 전체를 Providers로 감싸줍니다 */}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
66
page/src/app/page.tsx
Normal file
66
page/src/app/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import TopNav from "@/components/layout/TopNav";
|
||||
import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||
import MainContent from "@/components/player/MainContent";
|
||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||
import PlayerBar from "@/components/player/PlayerBar";
|
||||
|
||||
// 화면 모드 타입 정의
|
||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||
|
||||
export default function MusicPlayerLayout() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<any>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||
const handleHome = () => {
|
||||
if (selectedServer) {
|
||||
setViewMode("SERVER_DETAIL");
|
||||
} else {
|
||||
setViewMode("SERVER_LIST");
|
||||
setSelectedServer(null);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 실행 시
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setViewMode("SEARCH_RESULT");
|
||||
};
|
||||
|
||||
// 서버 선택 시
|
||||
const handleSelectServer = (server: any) => {
|
||||
setSelectedServer(server);
|
||||
setViewMode("SERVER_DETAIL");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
|
||||
<TopNav
|
||||
onSearch={handleSearch}
|
||||
onHome={handleHome}
|
||||
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<LeftSidebar />
|
||||
<MainContent
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
selectedServer={selectedServer}
|
||||
setSelectedServer={setSelectedServer}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onSelectServer={handleSelectServer}
|
||||
/>
|
||||
<QueueSidebar />
|
||||
</div>
|
||||
|
||||
<PlayerBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
page/src/components/Providers.tsx
Normal file
7
page/src/components/Providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
25
page/src/components/layout/LeftSidebar.tsx
Normal file
25
page/src/components/layout/LeftSidebar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { ListMusic, Library } from "lucide-react";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
return (
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<Library size={20} /> 내 플레이리스트
|
||||
</button>
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<ListMusic size={20} /> 좋아요 표시한 곡
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<hr className="border-neutral-800" />
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
|
||||
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
94
page/src/components/layout/TopNav.tsx
Normal file
94
page/src/components/layout/TopNav.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TopNavProps {
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
selectedServer: any; // 🌟 추가: 선택된 서버 정보
|
||||
}
|
||||
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [term, setTerm] = useState("");
|
||||
|
||||
const handleSearchClick = () => {
|
||||
// 🌟 서버 선택 여부 체크
|
||||
if (!selectedServer) {
|
||||
alert("먼저 왼쪽 목록에서 관리할 서버를 선택해주세요!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
};
|
||||
|
||||
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
|
||||
const handleHomeClick = () => {
|
||||
setTerm(""); // 검색창 비우기
|
||||
onHome(); // 원래 홈 기능 실행
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
|
||||
{/* 1. 왼쪽: 로고 */}
|
||||
<div className="flex items-center gap-2 text-xl font-bold text-white w-52">
|
||||
<ListMusic className="text-green-500" />
|
||||
<span>MusicBot</span>
|
||||
</div>
|
||||
|
||||
{/* 2. 가운데: 검색창 & 홈 버튼 */}
|
||||
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
|
||||
className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
|
||||
title="홈으로"
|
||||
>
|
||||
<Home size={22} />
|
||||
</button>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
|
||||
placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
|
||||
className={`w-full h-10 pl-10 pr-4 rounded-full bg-neutral-800 border border-neutral-700 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/30 transition-all ${!selectedServer ? 'opacity-50 cursor-not-allowed' : 'opacity-100'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
|
||||
<div className="flex items-center justify-end w-auto min-w-[13rem]">
|
||||
{status === "loading" ? (
|
||||
<div className="text-sm text-neutral-400">로딩 중...</div>
|
||||
) : status === "authenticated" && session?.user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-neutral-800/80 pr-4 pl-1 py-1 rounded-full border border-neutral-700 shadow-sm cursor-default">
|
||||
<img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
|
||||
<span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
|
||||
</div>
|
||||
<button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => signIn("discord")} className="cursor-pointer flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752C4] text-white px-5 py-2 rounded-full font-medium transition-colors text-sm shadow-md">
|
||||
<LogIn size={16} /> Discord 로그인
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
212
page/src/components/player/MainContent.tsx
Normal file
212
page/src/components/player/MainContent.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX } from "lucide-react";
|
||||
import { ViewMode } from "@/app/page";
|
||||
|
||||
interface MainContentProps {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
selectedServer: any;
|
||||
setSelectedServer: (server: any) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
onSelectServer: (server: any) => void;
|
||||
}
|
||||
|
||||
export default function MainContent({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
selectedServer,
|
||||
setSelectedServer,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
onSelectServer,
|
||||
}: MainContentProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// 권한 계산 함수
|
||||
const getPermissionLabel = (server: any) => {
|
||||
if (!server) return "알 수 없음";
|
||||
if (server.owner) return "👑 서버 주인";
|
||||
try {
|
||||
const perms = BigInt(server.permissions);
|
||||
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
|
||||
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
|
||||
return "👤 일반 멤버";
|
||||
} catch {
|
||||
return "👤 일반 멤버";
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 서버 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
setIsFetching(true);
|
||||
const cached = sessionStorage.getItem("filtered_servers");
|
||||
if (cached) {
|
||||
setServers(JSON.parse(cached));
|
||||
setIsFetching(false);
|
||||
}
|
||||
|
||||
fetch("/api/servers")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setServers(data);
|
||||
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
|
||||
}
|
||||
})
|
||||
.finally(() => setIsFetching(false));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// 2. 검색 결과 불러오기
|
||||
useEffect(() => {
|
||||
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
||||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
|
||||
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||
.then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (res.ok && Array.isArray(data)) {
|
||||
setSearchResults(data);
|
||||
} else {
|
||||
console.error("Search Error:", data);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Fetch Error:", err))
|
||||
.finally(() => setIsSearching(false));
|
||||
}
|
||||
}, [viewMode, searchQuery]);
|
||||
|
||||
// 로그인 안 된 상태
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
|
||||
<Server size={48} className="text-neutral-500 mb-4" />
|
||||
<h2 className="text-2xl font-bold">로그인이 필요합니다</h2>
|
||||
<p className="text-neutral-400">서비스를 이용하시려면 디스코드 로그인을 해주세요.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 세션 로딩 중
|
||||
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
|
||||
|
||||
{/* 화면 1: 서버 목록 */}
|
||||
{viewMode === "SERVER_LIST" && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
|
||||
{isFetching && servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<p>서버 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => onSelectServer(server)}
|
||||
className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md"
|
||||
>
|
||||
<div className="aspect-square bg-neutral-700 rounded-full mb-4 overflow-hidden shadow-lg flex items-center justify-center">
|
||||
{server.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
) : <Server size={32} className="text-neutral-500" />}
|
||||
</div>
|
||||
<h3 className="font-semibold text-center truncate">{server.name}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 화면 2: 서버 상세 대시보드 */}
|
||||
{viewMode === "SERVER_DETAIL" && selectedServer && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<button onClick={() => {
|
||||
setViewMode("SERVER_LIST");
|
||||
setSelectedServer(null);
|
||||
setSearchQuery("");
|
||||
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
|
||||
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
서버 목록으로 돌아가기
|
||||
</button>
|
||||
|
||||
<div className="flex items-end gap-6 mb-8">
|
||||
<div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
|
||||
{selectedServer.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
) : <Server size={64} className="text-neutral-500" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold uppercase text-neutral-400 tracking-wider">서버 대시보드</span>
|
||||
<h1 className="text-5xl font-black mt-2 mb-4">{selectedServer.name}</h1>
|
||||
<p className="text-neutral-400">이 서버에서 음악 봇이 활발하게 작동 중입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> 상세 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
||||
<p className="text-neutral-500 mb-1">서버 고유 ID</p>
|
||||
<p className="font-mono text-white">{selectedServer.id}</p>
|
||||
</div>
|
||||
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
||||
<p className="text-neutral-500 mb-1">나의 서버 권한</p>
|
||||
<p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 3: 검색 결과 목록 */}
|
||||
{viewMode === "SEARCH_RESULT" && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" 검색 결과</h2>
|
||||
<p className="text-neutral-400 mb-8 text-sm">봇을 통해 찾은 유튜브 검색 결과입니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<p>노래를 찾는 중입니다...</p>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20 text-neutral-500">
|
||||
<SearchX size={48} className="mb-4" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{searchResults.map((track) => (
|
||||
<div key={track.videoId} className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-4 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
<div className="absolute right-2 bottom-2 bg-green-500 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl">
|
||||
<Play fill="black" stroke="black" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white truncate">{track.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
page/src/components/player/PlayerBar.tsx
Normal file
47
page/src/components/player/PlayerBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import { SkipForward, SkipBack, Volume2, Pause } from "lucide-react";
|
||||
|
||||
export default function PlayerBar() {
|
||||
return (
|
||||
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
||||
<div className="flex items-center gap-4 w-1/4">
|
||||
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md"></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white hover:underline cursor-pointer">내 손을 잡아</span>
|
||||
<span className="text-xs text-neutral-400 hover:underline cursor-pointer">아이유(IU)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform text-black">
|
||||
<Pause size={18} fill="black" />
|
||||
</button>
|
||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||
<span>01:12</span>
|
||||
<div className="h-1 bg-neutral-600 rounded-full flex-1 cursor-pointer group">
|
||||
<div className="h-full bg-white rounded-full w-1/3 group-hover:bg-green-500 transition-colors relative">
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>03:16</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
||||
<Volume2 size={20} />
|
||||
<div className="w-24 h-1 bg-neutral-600 rounded-full cursor-pointer group">
|
||||
<div className="h-full bg-white rounded-full w-2/3 group-hover:bg-green-500 transition-colors"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
24
page/src/components/player/QueueSidebar.tsx
Normal file
24
page/src/components/player/QueueSidebar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
export default function QueueSidebar() {
|
||||
return (
|
||||
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
|
||||
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<div key={item} className="flex items-center gap-3 group cursor-pointer">
|
||||
<div className="w-10 h-10 bg-neutral-800 rounded flex-shrink-0"></div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
||||
{item === 1 ? "Hype Boy" : "Supernova"}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 truncate">
|
||||
{item === 1 ? "NewJeans" : "aespa"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
38
page/src/lib/Logger.ts
Normal file
38
page/src/lib/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")
|
||||
}
|
||||
24
page/src/lib/Redis.ts
Normal file
24
page/src/lib/Redis.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Redis as RedisClass } from "ioredis";
|
||||
|
||||
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
|
||||
|
||||
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
|
||||
const globalForRedis = global as unknown as {
|
||||
redisClient: RedisClass | undefined;
|
||||
};
|
||||
|
||||
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
|
||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
|
||||
|
||||
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
|
||||
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
|
||||
|
||||
// 연결 성공 시 로그 한 번만 찍기
|
||||
Redis.on("connect", () => {
|
||||
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
|
||||
});
|
||||
|
||||
Redis.on("error", (err) => {
|
||||
console.error("❌ [Next.js] Redis 연결 에러:", err);
|
||||
});
|
||||
34
page/tsconfig.json
Normal file
34
page/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user