Compare commits

..

10 Commits

Author SHA1 Message Date
5811c16c20 perf(search): 유튜브뮤직 검색과 ytsearch 폴백을 병렬 실행 (실험 브랜치)
측정으로 확인한 낭비: YoutubeMusic.getSearchUrl 적중률이 6/10(평균 548ms)이라,
빗나가는 40% 에서는 YTM 을 끝까지 기다린 뒤 다시 ytsearch(~0.5초)를 도는 순차 대기가
통째로 버려지고 있었다.

변경: resolveQuery 기본 경로에서 ytsearch 폴백 검색을 먼저 띄워두고 YTM 결과를 기다린다.
  - YTM 적중  -> 폴백 결과는 버리고 기존과 동일하게 동작(Lavalink 검색 1회가 헛돌 뿐)
  - YTM 빗나감 -> 이미 진행 중인 폴백 결과를 그대로 사용(추가 대기 0)
  - 버려지는 폴백 promise 는 catch 를 달아 unhandledRejection 방지
  - " Topic" 중복 부가 방지 조건 추가

실측 A/B(YTM 이 빗나가는 3곡, 검색+해석+리졸브 구간):
  기존 1500 / 1688 / 1391ms  ->  신규 1224 / 1180 / 1399ms
  재생은 3곡 모두 성공, 관찰 중 멈춤 0.
남은 병목: 음성접속 핸드셰이크(0.5~1.3초)는 디스코드 왕복이라 단축 여지가 거의 없다.
2026-09-18 20:02:16 +09:00
fed948ca02 Merge branch 'master' into experiment/potoken-direct 2026-09-18 19:54:46 +09:00
ef16b67839 experiment: 내부 API(VISIONOS) 리졸버 상세 검증 — 전 케이스 통과, 32분 연속재생 무결
운영 리졸버 + 내부API 0순위 조합을 테스트 포트(8780)에 올리고, 운영 Lavalink(.6)로
실제 재생을 검증했다. 프로덕션(8779/Lavalink/master)은 변경하지 않았다.

케이스별 결과(첫 소리 / 관찰 중 멈춤):
  짧은영상 7초     성공   888ms  (재생 후 정상 종료. 관찰창이 곡보다 길어 판정 오탐 1회 발생)
  짧은영상 18초    성공  1437ms  0/2
  일반곡(K-pop)    성공  2017ms  0/3
  해외곡           성공  4209ms  0/3
  스포티파이 URL   성공  2753ms  0/3
  플레이리스트120  성공  3147ms  0/3
  곡 전환          성공  2982ms  0/2 (Ditto -> HEYA, 6.0s)
  긴 곡(1시간)     성공  1430ms  0/3
  32분 연속재생    성공   888ms  0/383  <- position 5,180 -> 1,920,180ms 정확히 선형 증가
리졸버 경로 통계: 내부API 11회 / 폴백 0회 / 내부API 실패 0회

참고: SABR 직결은 55초에서 403으로 끊겼던 반면, 이 방식은 32분간 무결. 포맷도 itag 251 동일.
남은 과제(운영 적용 전): visitorData 를 봇이 2시간마다 갱신하는 값과 공유하도록 배선.
현재는 고정 파일(/tmp/vd.txt)을 읽는 프로토타입이라 만료 시 폴백으로 떨어진다.
연령제한곡은 샘플 확보 실패로 미검증(폴백 경로가 쿠키로 처리).
2026-09-17 23:33:11 +09:00
3c6dbc9631 experiment: 내부 API 리졸버 후보 구현 채움(직전 커밋에 빈 파일이 올라감) 2026-09-17 19:07:16 +09:00
e7283ae862 experiment: 더 빠른 경로 발견 — 유튜브 내부 API(VISIONOS) 직접 호출로 리졸브 0.3초
yt-dlp 호출 자체를 없애는 경로를 탐색해 실측했다.

[탐색 과정]
- 내부 player API 를 클라이언트별로 호출해 비교:
  ANDROID_VR/TVHTML5/WEB_EMBEDDED = LOGIN_REQUIRED·ERROR, WEB = SABR 전용(직접 URL 없음)
  IOS = 0.32s 로 직접 URL(itag 140) 획득했으나 뒷부분 range 요청이 403 → 이어받기 불가
- 정상 재생되는 yt-dlp URL 의 c= 파라미터가 VISIONOS 임을 확인하고 동일 클라이언트로 재현.
  visitorData 없이 호출하면 LOGIN_REQUIRED, 넣으면 status=OK.

[결과] VISIONOS + visitorData 직접 호출
- 추출 0.30~0.38s (현행 상주 리졸버 1.7~2.3s, 기존 subprocess 4.2~4.7s)
- itag 251 로 yt-dlp 결과와 동일 포맷, Lavalink loadType=track
- range 0 / 1MB / 2MB / 3MB 전부 206 → 이어받기 정상(SABR·IOS 와 다름)
- 실제 재생: 90초 끊김 0/17, 일반곡·곡전환 성공. 첫 소리 2.0~2.3s
- 리졸버에 0순위로 넣고 실패 시 상주 lib → subprocess 로 폴백(경로 통계 innertube 5 / lib 1 / 실패 0)

미해결/주의: visitorData 를 정적 파일에서 읽는 프로토타입이라, 운영 적용 시 봇이 2시간마다
생성하는 visitorData 를 공유받도록 배선해야 한다. 검색어(비 URL) 입력은 영상 ID 가 없어
기존 경로로 폴백한다. 연령제한곡은 미검증(폴백 경로가 쿠키로 처리).
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않았다.
2026-09-17 19:06:47 +09:00
5d5bf9b3f3 experiment: SABR 끊김은 업스트림 미완성으로 해결 불가 / 대안 '상주 리졸버' 검증 성공
[1] 재생 중 끊김(~55초) 해결 시도 — 전부 실패
  - poToken 정상 주입 + 프로덕션 동일 설정 : 55초 끊김 동일
  - 클라이언트 WEB 단독(SABR 경로 강제)    : 55초 끊김 동일(decode 오류)
  - 프록시(.4:3128) 제거                   : 55초 끊김 동일
  원인: 첫 청크 재생 후 '이어받기' 요청이 실패(정적 경로 403 / SABR 경로 decode).
  feat/sabr-support 는 열린 PR #229, 마지막 커밋 2026-07-27 로 미병합 진행 중 기능.
  => 우리 쪽 설정으로 고칠 수 없음. SABR 직결 보류 확정.

[2] 새 방식: yt-dlp 를 '상주 라이브러리'로 호출하는 리졸버(프로토타입 첨부)
  - zipapp(/usr/local/bin/yt-dlp) 을 sys.path 에 넣어 import, YoutubeDL 인스턴스 1회 생성
  - 매 요청 프로세스 기동(0.40s) 제거 + player_client=default 단독
  - 실패 시 기존 subprocess 경로로 자동 폴백(안전)
  리졸브 실측: 1.58~2.30s (기존 subprocess 3곡 평균 4.2~4.7s)
  재생 검증(프로덕션 Lavalink .6 + 이 리졸버):
    일반곡 2656ms / 해외곡 2968ms / 직접URL 1900ms / 곡전환 성공
    90초 장시간: 85초 진행, 끊김 0/17  ← SABR(55초 실패) 대비 안정
프로덕션(리졸버 8779, Lavalink, master)은 변경하지 않음. 테스트는 8780 포트로만 수행.
2026-09-17 15:29:21 +09:00
7f057d818c experiment: SABR 빌드 확대 검증 — 첫 재생은 빠르나 ~55초 뒤 403으로 끊김(적용 불가)
SelfTest 확장: 관찰시간(SELFTEST_WATCH_SEC), 곡전환(SELFTEST_SECOND_QUERY) 지원.
position 샘플링 주기를 Lavalink 보고 주기(5초)에 맞춰 멈춤 오탐 제거.

격리 Lavalink(.9:2334, SABR 빌드) + ytresolver OFF 결과:
  일반곡(K-pop)      성공  첫소리 1455ms  20초 관찰 끊김 0
  해외곡             성공  첫소리 1696ms  20초 관찰 끊김 0
  직접 URL           성공  첫소리  932ms  20초 관찰 끊김 0
  긴 곡(1시간)       성공  첫소리 3865ms  20초 관찰 끊김 0
  플레이리스트(120곡) 성공  첫소리 5656ms  20초 관찰 끊김 0
  곡 전환            성공  (Ditto → HEYA, 6.0s)
  라이브 스트림      실패  ※ 현행 방식(1.18.2+ytresolver)도 동일 실패 → 기존 한계
  장시간 90초        실패  50초 재생 후 정지, 2곡 재현
장시간 실패 원인: 재생 중 googlevideo 403
  (PersistentHttpStream.connect → "Not success status code: 403")
대조군(현행 방식) 동일 90초 테스트: 85초 진행, 끊김 0 → 현행이 안정적.

결론: SABR 빌드는 첫 재생이 약 3초 빠르지만 곡 길이(3~4분)를 못 버티므로 적용 불가.
프로덕션(Lavalink .6, 리졸버, master)은 변경하지 않았다.
2026-09-17 15:08:47 +09:00
4fbf8d9326 experiment: SABR 지원 빌드로 직결 재생 성공 — ytresolver 제거 가능성 확인
이전 결론(직결 불가)은 youtube-source 1.18.2 '릴리스' 기준이었다. upstream 에
feat/sabr-support 브랜치(커밋 02f536d "SABR stream support")가 있고, 스냅샷
저장소에 빌드가 배포돼 있어 격리 Lavalink(.9:2334)에 올려 재검증했다.

빌드 확인: 1.18.2 에 없던 dev/lavalink/youtube/sabr/* 클래스 14개 추가(SabrStream 등).
(참고: main 최신 스냅샷 f45bbb7 에는 SABR 미포함 — 감지 문자열만 존재)

실측(자가 테스트, 동일 3곡 + 1곡, 프로덕션 무영향):
  A) 현재 방식(.6 Lavalink 1.18.2 + ytresolver): 4437~4884ms, 성공
  B) SABR 빌드 + ytresolver OFF(직결)          : 1155~2816ms, 성공
→ 첫 소리까지 약 3초 단축. yt-dlp 단계 자체가 사라져 구조도 단순해진다.

미검증/주의: 연령제한곡·라이브·장시간 재생 안정성, 비공식 브랜치(미병합) 빌드 사용.
프로덕션 Lavalink(.6)·리졸버·master 는 변경하지 않았다.
2026-09-17 14:48:35 +09:00
7994e97631 experiment: 4번 추가 검증 — Lavalink 직결 전면 불가 확인 + 리졸버 고속화 후보 발견
POTOKEN_INJECT 플래그 추가(기본 true). 격리 테스트 Lavalink(.9:2334)를 별도로 띄워
클라이언트/OAuth/poToken 조합을 바꿔가며 직결 재생을 검증했다. 프로덕션 무영향.

[직결 재생 — 전부 실패]
- ANDROID_VR 우선 + OAuth + poToken : 실패
- ANDROID_VR 단독 + OAuth off       : 실패
- ANDROID_VR 단독 + OAuth/poToken off: 실패
Lavalink 측 실패 사유(클라이언트별):
  ANDROID_VR "This video requires login" / TVHTML5 "page needs to be reloaded"
  WEB "No supported audio streams available"(응답이 sabr=1 + serverAbrStreamingUrl 뿐)
  WEB_EMBEDDED "player configuration error" / MWEB 403
→ youtube-source 1.18.2(최신)는 SABR 미지원. 어떤 조합으로도 직결 불가 → ytresolver 필수.

[리졸버 고속화 — 유효한 개선 발견]
yt-dlp 클라이언트별 실측(동일 영상 5개, --get-url):
  현재 default,web_safari,tv : 5/5 성공, 평균 2.95s, itag 251
  default 단독               : 5/5 성공, 평균 2.00s, itag 251  ← 약 1.0s 단축
  android_vr 단독            : 1.6s로 가장 빠르나 오디오 전용 포맷 없음(항상 itag 18)
                               → Lavalink loadtracks 실패, 사용 불가
검증: default 단독 URL을 Lavalink가 loadType=track 로 정상 로드,
      실제 재생까지 성공(자가 테스트 position 증가 확인).
2026-09-17 14:40:42 +09:00
6762b8faba experiment: ytresolver 제거(poToken 직결) 검증 — 결론 '불가'
4번 안(yt-dlp 단계 제거) 독립 검증용 실험 브랜치. master 에는 병합하지 않는다.

- Config.ytresolverEnabled 플래그 추가(YTRESOLVER_ENABLED, 기본 true=기존 동작).
  false 면 resolvePlayable 이 Lavalink youtube-source(poToken) encoded 를 그대로 사용.
- SelfTest 추가: 사람이 음성채널에 없어도 봇이 스스로 입장→재생→position 증가를
  확인해 실제 오디오 흐름 여부를 판정(환경변수 미설정 시 비활성).

실측 결과(테스트봇, Redis off, 프로덕션 무영향):
- A) ytresolver ON : 재생 성공, 첫 소리까지 4779ms (오디오 리졸브 3166ms)
- B) ytresolver OFF: 첫 소리까지 1279~1391ms 로 빠르지만 재생 실패.
     Lavalink 예외 "All clients failed to load the item." 반복, position 0 고정.
     서로 다른 2곡에서 동일 재현 → 곡 특성 아닌 구조적 차단(SABR).
결론: poToken 주입만으로는 스트림 로드가 안 되므로 ytresolver 는 계속 필요.
2026-09-17 13:16:44 +09:00
18 changed files with 754 additions and 1093 deletions

View File

@@ -0,0 +1,72 @@
"""
[실험 후보] 유튜브 내부 API(VISIONOS 클라이언트) 직접 호출로 오디오 URL을 얻는 리졸버 조각.
운영 리졸버(scripts/ytresolver.py)의 _do_resolve 맨 앞에 0순위로 끼워 넣어 쓴다.
yt-dlp 를 아예 거치지 않으므로 리졸브가 0.3초대로 떨어진다(상주 lib 1.7~2.3초).
동작 조건
- 대상이 유튜브 영상 URL 이어야 한다(영상 ID 필요). 검색어면 None 을 돌려 기존 경로로 폴백.
- visitorData 가 있어야 한다. 없으면 playabilityStatus=LOGIN_REQUIRED 로 실패한다.
운영 적용 시에는 봇이 2시간마다 생성하는 visitorData 를 공유받도록 배선해야 한다.
실측(2026-09-17)
추출 0.30~0.38s / itag 251(yt-dlp 와 동일 포맷) / Lavalink loadType=track
range 0·1MB·2MB·3MB 전부 206 (이어받기 정상) / 실제 재생 90초 끊김 0
"""
import json
import os
import re
import urllib.request
from urllib.parse import unquote
# 운영 적용 시에는 파일 대신 봇이 갱신하는 값을 받아오도록 교체할 것.
VISITOR_DATA_PATH = os.environ.get("YT_VISITOR_DATA_PATH", "/tmp/vd.txt")
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/26.0 Safari/605.1.15")
_VID_RE = re.compile(r"(?:v=|youtu\.be/|/shorts/|/embed/|/live/)([A-Za-z0-9_-]{11})")
def _visitor_data():
if not os.path.exists(VISITOR_DATA_PATH):
return ""
return unquote(open(VISITOR_DATA_PATH).read().strip())
def innertube_resolve(target):
"""유튜브 URL -> 최고 비트레이트 오디오 직링크. 불가하면 None(호출측이 폴백)."""
m = _VID_RE.search(target)
visitor = _visitor_data()
if not m or not visitor:
return None
ctx = {
"clientName": "VISIONOS", "clientVersion": "1.02",
"deviceMake": "Apple", "deviceModel": "RealityDevice17,1",
"osName": "visionOS", "osVersion": "26.5.23O471",
"hl": "ko", "gl": "KR", "visitorData": visitor,
}
body = {"videoId": m.group(1), "context": {"client": ctx},
"contentCheckOk": True, "racyCheckOk": True}
req = urllib.request.Request(
"https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"User-Agent": _UA,
"X-Goog-Visitor-Id": visitor,
"X-Youtube-Client-Name": "101",
"X-Youtube-Client-Version": "1.02",
},
)
data = json.loads(urllib.request.urlopen(req, timeout=15).read())
if data.get("playabilityStatus", {}).get("status") != "OK":
return None
formats = (data.get("streamingData") or {}).get("adaptiveFormats") or []
audio = [f for f in formats
if str(f.get("mimeType", "")).startswith("audio") and f.get("url")]
if not audio:
return None
audio.sort(key=lambda f: f.get("bitrate", 0), reverse=True)
return audio[0]["url"]

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
ytresolver - yt-dlp 오디오 URL 리졸버 (HTTP 서비스)
봇(GuildPlayer.resolvePlayable)이 유튜브/스포티파이-미러 트랙을 재생하기 직전에 호출한다.
Lavalink의 youtube-plugin은 집 IP의 SABR 차단/성인인증(연령제한) 때문에 실재생이 막히므로,
여기서 yt-dlp로 "직접 오디오 URL"을 뽑아 봇에 돌려주고 봇은 그 URL을 Lavalink http 소스로 재생한다.
동작:
1) 먼저 익명(쿠키 없이)으로 시도 -> 일반 영상은 계정을 태우지 않는다(밴 리스크 최소화).
2) 실패하면 쿠키 + node(EJS 시그니처 해독) 로 재시도 -> 성인인증(연령제한) 영상 우회.
환경변수:
YTRESOLVER_COOKIE : Netscape 쿠키 파일 경로 (기본 /root/bot/db/ytcookie.txt)
YTRESOLVER_PORT : 리슨 포트 (기본 8779)
YTDLP : yt-dlp 실행 경로 (기본 /usr/local/bin/yt-dlp)
HTTP API:
GET /resolve?url=<유튜브 URL | ytsearch: | 검색어> -> {"url": "<직접 오디오 URL>"}
GET /health -> {"ok": true}
"""
import json, subprocess, time, threading, os, sys
sys.path.insert(0, os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")) # zipapp 내부 모듈 임포트
import yt_dlp
_YDL_OPTS = {"format": "bestaudio/best", "noplaylist": True, "quiet": True,
"no_warnings": True, "nocheckcertificate": True, "socket_timeout": 15,
"extractor_args": {"youtube": {"player_client": ["default"]}}}
_ydl = yt_dlp.YoutubeDL(_YDL_OPTS) # 프로세스 상주(임포트/초기화 1회)
_ydl_lock = threading.Lock()
def lib_resolve(target):
print("[lib] enter " + target[:40], file=sys.stderr, flush=True)
# yt-dlp 를 라이브러리로 직접 호출: 매 요청 프로세스 기동(약 0.4s) 을 없앤다.
q = target if (target.startswith("http") or target.startswith("ytsearch")) else "ytsearch1:" + target
with _ydl_lock:
info = _ydl.extract_info(q, download=False)
print("[lib] got info keys=" + str(list(info.keys())[:6]), file=sys.stderr, flush=True)
if info.get("entries"):
info = info["entries"][0]
u = info.get("url")
if not u:
for f in (info.get("requested_formats") or []):
if f.get("url"): u = f["url"]; break
return u
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
CACHE = {}
INFLIGHT = {} # target -> {"event": Event, "url": str|None} 진행 중 리졸브 합치기(coalescing)
LOCK = threading.Lock()
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
# 속도/안정성 공통 플래그
COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings",
"--no-check-certificate", "--socket-timeout", "15"]
def run_ytdlp(target, use_cookies=False):
if use_cookies and os.path.exists(COOKIE_FILE):
# 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요
cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE,
*COMMON, "--extractor-args", "youtube:player_client=web"]
else:
# 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합.
cmd = [YTDLP, *COMMON,
"--extractor-args", "youtube:player_client=default,web_safari,tv"]
cmd += ["--get-url", target]
return subprocess.run(cmd, capture_output=True, text=True, timeout=45)
def first_url(out):
lines = (out.stdout or "").strip().splitlines()
return lines[0] if lines else None
def _do_resolve(target):
# 1순위: 라이브러리 상주 호출(가장 빠름). 실패하면 기존 subprocess 경로로 폴백.
try:
u = lib_resolve(target)
if u and u.startswith("http"):
print("[result] OK(lib) " + u[:50], file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (u, time.time() + TTL)
return u
except Exception as e:
print("[lib-fail] " + str(e)[:150], file=sys.stderr, flush=True)
try:
out = run_ytdlp(target, use_cookies=False)
url = first_url(out)
if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE):
print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True)
out = run_ytdlp(target, use_cookies=True)
url = first_url(out)
except subprocess.TimeoutExpired:
print("[result] TIMEOUT", file=sys.stderr, flush=True)
return None
if not (url and url.startswith("http")):
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
return None
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (url, time.time() + TTL)
return url
def resolve(target):
now = time.time()
with LOCK:
hit = CACHE.get(target)
if hit and hit[1] > now:
return hit[0]
# 같은 target이 이미 리졸브 중이면, 새 yt-dlp를 띄우지 않고 그 결과를 기다린다.
# (봇이 음성 접속과 동시에 미리 리졸브를 걸어두면, 실제 재생 요청이 이 진행분에 합쳐져
# 음성 접속 시간과 리졸브 시간이 겹쳐 첫 재생 지연이 크게 줄어든다.)
entry = INFLIGHT.get(target)
owner = entry is None
if owner:
entry = {"event": threading.Event(), "url": None}
INFLIGHT[target] = entry
if not owner:
entry["event"].wait(50)
with LOCK:
hit = CACHE.get(target)
return hit[0] if hit and hit[1] > time.time() else entry.get("url")
# owner: 실제 리졸브 수행
url = None
try:
url = _do_resolve(target)
except Exception as e:
print(f"[result] ERR {str(e)[:120]}", file=sys.stderr, flush=True)
finally:
entry["url"] = url
with LOCK:
INFLIGHT.pop(target, None)
entry["event"].set()
return url
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
p = urlparse(self.path)
q = parse_qs(p.query)
if p.path == "/health":
self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)})
return
if p.path != "/resolve":
self._send(404, {"error": "not found"})
return
target = (q.get("url") or q.get("q") or [None])[0]
print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True)
if not target:
self._send(400, {"error": "missing url/q"})
return
if not (target.startswith("http") or target.startswith("ytsearch")):
target = "ytsearch1:" + target
url = resolve(target)
if url:
self._send(200, {"url": url})
else:
self._send(502, {"error": "resolve failed"})
def _send(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
if __name__ == "__main__":
print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})",
file=sys.stderr, flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

View File

@@ -261,6 +261,8 @@ export class GuildPlayer {
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */ * youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
private async resolvePlayable(track: QueueTrack): Promise<string> { private async resolvePlayable(track: QueueTrack): Promise<string> {
try { try {
// 실험: ytresolver 비활성화 시 Lavalink youtube-source(poToken)로 직접 재생 — yt-dlp 단계 생략.
if (!Config.ytresolverEnabled) return track.encoded;
const info: any = (track as any).info ?? {}; const info: any = (track as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded; if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;

View File

@@ -9,10 +9,6 @@ import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle"; import { shuffle } from "../utils/Shuffle";
import { Spotify } from "../utils/api/Spotify"; import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
/** 해석 결과. pendingSpotify: 스포티파이 재생목록에서 아직 유튜브로 매칭하지 않은 나머지 곡(큐 추가 후 백그라운드 매칭). */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; pendingSpotify?: SongMeta[] };
export class LavalinkManager { export class LavalinkManager {
public shoukaku: Shoukaku; public shoukaku: Shoukaku;
@@ -47,6 +43,11 @@ export class LavalinkManager {
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다. * BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
*/ */
private refreshPoToken(): void { private refreshPoToken(): void {
// [실험] poToken 주입을 끄고 비교하기 위한 플래그. 미설정 시 기존 동작(주입).
if (process.env.POTOKEN_INJECT?.trim()?.toLocaleLowerCase() === "false") {
Logger.warn(`[LavalinkManager] [실험] POTOKEN_INJECT=false — poToken 주입 생략`);
return;
}
const script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs"); const script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs");
const child = spawn(process.execPath, [script], { const child = spawn(process.execPath, [script], {
env: { env: {
@@ -113,30 +114,17 @@ export class LavalinkManager {
* 오디오 URL이 이미 준비되도록 한다. * 오디오 URL이 이미 준비되도록 한다.
* 못 찾으면 null. * 못 찾으면 null.
*/ */
public async resolveQuery(query: string): Promise<Resolved | null> { public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set<string> } | null> {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim()); const { isUrl, text, flags } = parseLink(query.trim());
// 스포티파이 플레이리스트/앨범: Lavalink(LavaSrc)는 Spotify API 403으로 실패하므로 봇이 곡 목록을 직접 받아
// 곡마다 유튜브 원곡을 매칭한다. 첫 곡만 지금 매칭해 바로 재생하고, 나머지는 큐 추가 후 백그라운드에서 이어 붙인다.
const collection = isUrl ? Spotify.parseCollection(text) : null;
if (collection) {
const entries = await Spotify.getCollectionTracks(collection.kind, collection.id);
if (flags.has('s')) shuffle(entries);
let first: Track | null = null;
let i = 0;
while (!first && i < entries.length && i < 5) first = await this.findYoutubeMatch(entries[i++]);
if (!first) return null;
this.prewarmTrack(first);
return { tracks: [first], playlist: true, flags, pendingSpotify: entries.slice(i) };
}
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색) // ('-p' 플래그를 붙이면 스포티파이 우선 검색)
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다. // 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 끝까지 기다린 뒤 다시 // YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
// ytsearch(~0.5초)를 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 // 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
// 추가 대기가 없다. (적중 시엔 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.) // (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
let searchText: string; let searchText: string;
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null; let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
if (isUrl) searchText = text; if (isUrl) searchText = text;
@@ -150,19 +138,14 @@ export class LavalinkManager {
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음 if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
} }
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도). // 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트+길이)로 // 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
// 유튜브 검색 결과 중 같은 곡의 원곡 오디오를 골라 대체한다. // 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
// (유튜브뮤직 검색은 현재 노래 대신 팟캐스트·분석 영상을 주는 경우가 많아 엉뚱한 곡이 걸렸다)
const spotifyTrackId = Spotify.parseTrackId(searchText); const spotifyTrackId = Spotify.parseTrackId(searchText);
if (spotifyTrackId) { if (spotifyTrackId) {
const meta = await Spotify.getTrackById(spotifyTrackId); const meta = await Spotify.getTrackById(spotifyTrackId);
if (meta) { if (meta) {
const matched = await this.findYoutubeMatch(meta); const q = `${meta.title} ${meta.artist}`.trim();
if (matched) { searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
this.prewarmTrack(matched);
return { tracks: [matched], playlist: false, flags };
}
searchText = `ytsearch:${meta.title} ${meta.artist}`.trim();
} }
} }
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic"; if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
@@ -193,63 +176,23 @@ export class LavalinkManager {
*/ */
public async addResolved( public async addResolved(
player: GuildPlayer, player: GuildPlayer,
resolved: Resolved, resolved: { tracks: Track[]; playlist: boolean; flags: Set<string> },
userId: string, userId: string,
) { ) {
if (resolved.playlist) { if (resolved.playlist) {
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId); await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
if (resolved.pendingSpotify?.length) {
this.appendSpotifyEntries(player, resolved.pendingSpotify, userId)
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 추가 실패: ${String(e)}`));
}
return; return;
} }
await player.addTrack(resolved.tracks[0], userId); await player.addTrack(resolved.tracks[0], userId);
} }
/**
* 스포티파이 곡 메타데이터로 유튜브를 검색해 같은 곡의 원곡 오디오 트랙을 고른다. 못 찾으면 null.
*/
public async findYoutubeMatch(meta: SongMeta): Promise<Track | null> {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) return null;
const artist = meta.artist.split(",")[0].trim();
try {
const result = await node.rest.resolve(`ytsearch:${artist} ${meta.title}`.trim());
const candidates = result?.loadType === LoadType.SEARCH ? result.data : [];
const picked = pickBestMatch(candidates, meta);
if (!picked) Logger.warn(`[LavalinkManager] 유튜브에서 같은 곡을 찾지 못함: "${meta.artist} - ${meta.title}"`);
return picked;
} catch (e) {
Logger.warn(`[LavalinkManager] 유튜브 매칭 검색 실패: "${meta.artist} - ${meta.title}" ${String(e)}`);
return null;
}
}
/** 스포티파이 재생목록의 나머지 곡을 순서대로 매칭해 큐에 이어 붙인다. 정지/퇴장하면 중단. */
private async appendSpotifyEntries(player: GuildPlayer, entries: SongMeta[], userId: string) {
const chunkSize = 5;
let added = 0;
for (let i = 0; i < entries.length; i += chunkSize) {
const found = await Promise.all(entries.slice(i, i + chunkSize).map((e) => this.findYoutubeMatch(e)));
// 그 사이 정지(큐 비움)했거나 봇이 나갔으면 더 붙이지 않는다
if (this.getPlayer(player.guild.id) !== player || player.queue.length === 0) {
Logger.info(`[LavalinkManager] 스포티파이 재생목록 추가 중단 (정지/퇴장), ${added}곡 추가됨`);
return;
}
const tracks = found.filter((t): t is Track => !!t);
if (tracks.length > 0) await player.addTracks(tracks, userId);
added += tracks.length;
}
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}/${entries.length}곡 추가 완료`);
}
/** /**
* 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget). * 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget).
* GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다. * GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
*/ */
public prewarmTrack(track: Track) { public prewarmTrack(track: Track) {
try { try {
if (!Config.ytresolverEnabled) return; // yt-dlp 미사용 실험 모드에서는 예열 자체가 불필요
const info: any = (track as any).info ?? {}; const info: any = (track as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return; if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;

View File

@@ -3,6 +3,7 @@ import { client, handler, Redis } from "../index";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { DB } from "../utils/Database"; import { DB } from "../utils/Database";
import { channelRegister } from "../commands/channel"; import { channelRegister } from "../commands/channel";
import { runSelfTest } from "../utils/SelfTest";
export const clientReady = async () => { export const clientReady = async () => {
if (!client.user) return; if (!client.user) return;
@@ -20,6 +21,9 @@ export const clientReady = async () => {
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`); Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
} }
// [실험 전용] 환경변수가 있을 때만 동작. 프로덕션에는 변수 미설정이라 아무 일도 안 한다.
runSelfTest();
if (!Config.dev) return; if (!Config.dev) return;
try { try {
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData)); const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));

View File

@@ -106,6 +106,11 @@ export const Config = {
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located. // yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""), ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
// ytresolver(yt-dlp) 사용 여부. false 면 Lavalink youtube-source(poToken 주입)로 직접 재생한다.
// 첫 재생에서 yt-dlp 단계(콜드 3.6~4.2s)를 통째로 없애지만, 집 IP의 SABR/403 차단에
// 다시 걸릴 수 있어 실험용 플래그로 둔다. 미설정 시 기존 동작(true) 유지.
ytresolverEnabled: (process.env.YTRESOLVER_ENABLED?.trim()?.toLocaleLowerCase() ?? "true") !== "false",
proxyUrl: process.env.PROXY_URL?.trim() || "", proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: { _redis: {

95
bot/src/utils/SelfTest.ts Normal file
View File

@@ -0,0 +1,95 @@
import { client, lavalinkManager } from "../index";
import { channelJoin } from "../commands/join";
import { channelRegister } from "../commands/channel";
import { Logger } from "./Logger";
/**
* [실험 전용] poToken 직결 재생 검증용 자가 테스트.
*
* SELFTEST_GUILD_ID / SELFTEST_VOICE_CHANNEL_ID 가 설정되면, 봇이 스스로 음성채널에 들어가
* 한 곡을 재생한 뒤 실제로 오디오가 흐르는지(position 이 증가하는지) 확인해 결과를 로그로 남긴다.
* 사람이 음성채널에 없어도 검증할 수 있게 하기 위한 것으로, 실험 브랜치에서만 사용한다.
*/
export async function runSelfTest() {
const guildId = process.env.SELFTEST_GUILD_ID?.trim();
const voiceChannelId = process.env.SELFTEST_VOICE_CHANNEL_ID?.trim();
const query = process.env.SELFTEST_QUERY?.trim() || "아이유 좋은 날";
if (!guildId || !voiceChannelId) return;
try {
Logger.info(`[SelfTest] 시작: guild=${guildId} voice=${voiceChannelId} query="${query}"`);
const guild = client.guilds.cache.get(guildId) ?? await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return Logger.error(`[SelfTest] guild 를 찾을 수 없습니다: ${guildId}`);
// Lavalink 노드 연결을 기다린다(연결 전이면 channelJoin 이 실패한다).
for (let i = 0; i < 60 && !lavalinkManager.hasReadyNode(); i++) {
await new Promise((r) => setTimeout(r, 500));
}
if (!lavalinkManager.hasReadyNode()) return Logger.error(`[SelfTest] Lavalink 노드 연결 대기 실패`);
// 음악 텍스트 채널이 등록돼 있어야 재생 세션을 만들 수 있다.
const textChannelId = process.env.SELFTEST_TEXT_CHANNEL_ID?.trim();
if (textChannelId) {
const embed = await channelRegister(guild, textChannelId);
Logger.info(`[SelfTest] 음악채널 등록: ${embed.data.title ?? "-"}`);
}
const t0 = Date.now();
const { player } = await channelJoin(guild, voiceChannelId);
if (!player) return Logger.error(`[SelfTest] 음성채널 참가 실패`);
const tJoin = Date.now();
Logger.info(`[SelfTest] 음성접속 완료 ${tJoin - t0}ms`);
await lavalinkManager.search(guild.id, query, client.user?.id ?? "selftest", player);
const tPlay = Date.now();
Logger.info(`[SelfTest] 재생 요청까지 ${tPlay - t0}ms (검색+해석+리졸브 ${tPlay - tJoin}ms)`);
// 실제 오디오가 흐르는지 확인: position 이 시간에 따라 증가해야 한다.
// SELFTEST_WATCH_SEC 로 관찰 시간을 늘리면 장시간 재생 안정성(중간 끊김)을 검증할 수 있다.
const watchSec = Number(process.env.SELFTEST_WATCH_SEC?.trim() || "9");
// Lavalink 는 position 을 약 5초 주기로 보고하므로 그 주기에 맞춰 샘플링해야 오탐이 없다.
const stepMs = 5000;
const steps = Math.max(3, Math.round((watchSec * 1000) / stepMs));
const samples: number[] = [];
let stallCount = 0;
for (let i = 0; i < steps; i++) {
await new Promise((r) => setTimeout(r, stepMs));
const pos = player.position;
if (samples.length > 0 && pos <= samples[samples.length - 1]) stallCount++;
samples.push(pos);
if (i % 3 === 0 || i === steps - 1) {
Logger.log(`[SelfTest] t+${((i + 1) * stepMs / 1000).toFixed(0)}s position=${pos} isPlaying=${player.isPlaying} track="${player.nowTrack?.info?.title ?? "-"}"`);
}
}
const advanced = samples[samples.length - 1] > samples[0] && samples[samples.length - 1] > 0;
if (advanced) {
Logger.ready(`[SelfTest] 결과: 재생 성공 ✅ (${(watchSec).toFixed(0)}초 관찰, position ${samples[0]} → ${samples[samples.length - 1]}, 멈춤구간 ${stallCount}/${steps - 1}, 첫 소리까지 약 ${tPlay - t0}ms)`);
} else {
Logger.error(`[SelfTest] 결과: 재생 실패 ❌ (position 이 진행하지 않음: ${samples.slice(0, 6).join(", ")})`);
return;
}
// 곡 전환 검증: 두 번째 곡을 큐에 넣고 스킵해서 실제로 다음 곡이 재생되는지 본다.
const secondQuery = process.env.SELFTEST_SECOND_QUERY?.trim();
if (secondQuery) {
const firstTitle = player.nowTrack?.info?.title ?? "";
await lavalinkManager.search(guild.id, secondQuery, client.user?.id ?? "selftest", player);
await new Promise((r) => setTimeout(r, 1500));
const tSkip = Date.now();
player.skip();
let switched = false;
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 1500));
const cur = player.nowTrack?.info?.title ?? "";
if (cur && cur !== firstTitle && player.position > 0) { switched = true; break; }
}
if (switched) {
Logger.ready(`[SelfTest] 곡전환: 성공 ✅ ("${firstTitle.slice(0, 25)}" → "${(player.nowTrack?.info?.title ?? "").slice(0, 25)}", ${Date.now() - tSkip}ms)`);
} else {
Logger.error(`[SelfTest] 곡전환: 실패 ❌ (다음 곡이 재생되지 않음)`);
}
}
} catch (err) {
Logger.error(`[SelfTest] 예외: ${String(err)}`);
}
}

View File

@@ -2,7 +2,6 @@
import "dotenv/config"; import "dotenv/config";
import { Logger } from "../Logger"; import { Logger } from "../Logger";
import { SongItem } from "../../types/Track"; import { SongItem } from "../../types/Track";
import type { SongMeta } from "../music/TrackMatch";
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? ""; const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
@@ -129,55 +128,14 @@ export const Spotify = {
return track?.url ?? null; return track?.url ?? null;
}, },
/** open.spotify.com/(intl-xx/)track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */ /** open.spotify.com/track/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
parseTrackId(text: string): string | null { parseTrackId(text: string): string | null {
const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?track\/|spotify:track:)([A-Za-z0-9]+)/); const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/);
return m ? m[1] : null; return m ? m[1] : null;
}, },
/** 스포티파이 플레이리스트/앨범 링크면 종류와 id를 추출. 아니면 null. */ /** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
parseCollection(text: string): { kind: "playlist" | "album"; id: string } | null { async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?|spotify:)(playlist|album)[\/:]([A-Za-z0-9]+)/);
return m ? { kind: m[1] as "playlist" | "album", id: m[2] } : null;
},
/**
* 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다.
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라
* (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다.
*/
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<SongMeta[]> {
try {
const response = await fetch(`https://open.spotify.com/embed/${kind}/${encodeURIComponent(id)}`, {
headers: { "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" },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`);
return [];
}
const html = await response.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
const list = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity?.trackList : null;
if (!Array.isArray(list)) {
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
return [];
}
return list
.filter((t: any) => t?.title && (t.entityType ?? "track") === "track")
.map((t: any) => ({
title: String(t.title),
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
durationMs: Number(t.duration) || 0,
}));
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
return [];
}
},
/** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
async getTrackById(id: string): Promise<SongMeta | null> {
try { try {
const token = await this.getToken(); const token = await this.getToken();
if (!token) return null; if (!token) return null;
@@ -193,7 +151,6 @@ export const Spotify = {
return { return {
title: track.name, title: track.name,
artist: (track.artists ?? []).map((a) => a.name).join(", "), artist: (track.artists ?? []).map((a) => a.name).join(", "),
durationMs: track.duration_ms ?? 0,
}; };
} catch (err) { } catch (err) {
Logger.error(`스포티파이 트랙 조회 실패: ${err}`); Logger.error(`스포티파이 트랙 조회 실패: ${err}`);

View File

@@ -11,9 +11,6 @@ const JUNK_RE = /(\bMR\b|inst(?:rumental)?|instrumental|karaoke|노래방|\bcove
export const ORIGIN = "https://music.youtube.com"; export const ORIGIN = "https://music.youtube.com";
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined; const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
const searchCache = new Map<string, string>(); const searchCache = new Map<string, string>();
// 검색 결과 행의 종류 표기(부제 첫 칸). 노래/동영상이 아닌 팟캐스트 에피소드·재생목록 등은 곡 후보에서 뺀다.
// (현재 YTM 검색이 노래 대신 '분석/루프' 같은 팟캐스트 에피소드를 상단에 주는 경우가 많다)
const NON_SONG_TYPES = new Set(["에피소드", "팟캐스트", "재생목록", "아티스트", "앨범", "싱글", "EP", "프로필", "Episode", "Podcast", "Playlist", "Artist", "Album", "Single", "Profile"]);
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨) // 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
const defaultCookies: Cookies[] = [ const defaultCookies: Cookies[] = [
@@ -134,7 +131,6 @@ export const YoutubeMusic = {
// 1. 최상단 'Top Result' 카드 파싱 // 1. 최상단 'Top Result' 카드 파싱
if (section.musicCardShelfRenderer) { if (section.musicCardShelfRenderer) {
const card = section.musicCardShelfRenderer; const card = section.musicCardShelfRenderer;
const cardType = card.subtitle?.runs?.[0]?.text;
const title = card.title?.runs?.[0]?.text; const title = card.title?.runs?.[0]?.text;
const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId; const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId;
@@ -152,7 +148,7 @@ export const YoutubeMusic = {
const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || []; const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || [];
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : ""; const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
if (videoId && title && !NON_SONG_TYPES.has(cardType)) { if (videoId && title) {
results.push({ results.push({
videoId, videoId,
url: `https://music.youtube.com/watch?v=${videoId}`, url: `https://music.youtube.com/watch?v=${videoId}`,
@@ -177,7 +173,6 @@ export const YoutubeMusic = {
let artist = "Unknown Artist"; let artist = "Unknown Artist";
const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || []; const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
if (NON_SONG_TYPES.has(subtitleRuns[0]?.text)) continue;
if (subtitleRuns.length >= 3) { if (subtitleRuns.length >= 3) {
artist = subtitleRuns[2]?.text || artist; artist = subtitleRuns[2]?.text || artist;

View File

@@ -1,45 +0,0 @@
import type { Track } from "shoukaku";
/** 스포티파이 곡 메타데이터(제목/아티스트/길이). 유튜브에서 같은 곡을 고르는 기준. */
export type SongMeta = { title: string; artist: string; durationMs: number };
// 원곡 오디오가 아닌 2차 영상(무대·직캠·리액션·분석·커버·루프 등). 원곡 제목 자체에 있는 단어는 감점하지 않는다.
const NOT_ORIGINAL_RE = /\b(cover|karaoke|inst|instrumental|mr|remix|sped up|slowed|nightcore|8d|live|reaction|dance practice|choreography|mirrored|fancam|performance|stage|teaser|behind|making|analysis|loop|1 ?hour|1hr|hours|tutorial|full album|playlist|medley|mashup|inkigayo|music ?core|music ?bank|m ?countdown|it's live)\b|노래방|커버|리액션|반응|분석|해석|직캠|무대|방송|인기가요|음악중심|엠카운트다운|뮤직뱅크|라이브|비하인드|연습|교차편집|모음|메들리|\d+\s?분|\d+\s?시간/gi;
const norm = (s: string) => s
.normalize("NFKC")
.toLocaleLowerCase()
.replace(/[’‘`´]/g, "'")
.replace(/[^\p{L}\p{N}']+/gu, " ")
.trim();
/**
* 유튜브 검색 결과 중 스포티파이 곡과 '같은 곡의 원곡 오디오'로 보이는 후보를 고른다.
* 기준: 제목 포함 여부, 아티스트 포함 여부, 재생시간 일치(스포티파이 길이와 ±수 초), 2차 영상 감점.
* 제목이 안 맞는 후보는 재생시간·아티스트가 정확히 맞을 때만 인정한다. 적당한 후보가 없으면 null.
*/
export function pickBestMatch(candidates: Track[], want: SongMeta): Track | null {
const wantTitle = norm(want.title);
const titleCore = norm(want.title.replace(/\s*[\(\[][^\)\]]*[\)\]]/g, "")) || wantTitle;
const coreTokens = titleCore.split(" ").filter(Boolean);
const artists = want.artist.split(",").map(norm).filter(Boolean);
let best: { track: Track; score: number } | null = null;
for (const track of candidates) {
const info = track.info;
if (!info || info.isStream) continue;
const hay = norm(`${info.title} ${info.author}`);
const titleHit = hay.includes(titleCore) ? 3 : coreTokens.every((t) => hay.includes(t)) ? 2 : 0;
const artistHit = artists.some((a) => hay.includes(a)) ? 1 : 0;
const junk = (info.title.match(NOT_ORIGINAL_RE) ?? []).some((w) => !wantTitle.includes(norm(w)));
const diffSec = want.durationMs > 0 ? Math.abs(info.length - want.durationMs) / 1000 : -1;
const durScore = diffSec < 0 ? 0 : diffSec <= 2 ? 3 : diffSec <= 5 ? 2 : diffSec <= 12 ? 1 : diffSec <= 40 ? 0 : -3;
const audioBonus = /\baudio\b|오디오/i.test(info.title) || / - topic$/i.test(info.author) ? 1 : 0;
// 제목이 안 맞으면 길이(±2초)와 아티스트가 모두 맞는 경우만 같은 곡으로 인정
if (titleHit === 0 && !(diffSec >= 0 && diffSec <= 2 && artistHit)) continue;
const score = titleHit + artistHit + durScore + audioBonus - (junk ? 4 : 0);
if (!best || score > best.score) best = { track, score };
}
return best && best.score > 0 ? best.track : null;
}

21
docker-compose.exp.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
music-exp:
build: ./bot
container_name: music-exp
restart: "no"
network_mode: host
env_file:
- ./bot/.env
environment:
- REDIS=false
- LAVALINK_HOST=192.168.10.6
- LAVALINK_PORT=2333
- SELFTEST_GUILD_ID=1352269198297923648
- SELFTEST_VOICE_CHANNEL_ID=1352269198914621465
- SELFTEST_TEXT_CHANNEL_ID=1421342055774289980
- SELFTEST_QUERY=aespa Supernova
- SELFTEST_WATCH_SEC=15
- YTRESOLVER_ENABLED=true
- YTRESOLVER_URL=http://192.168.10.5:8779
volumes:
- ./testdb:/db

View File

@@ -84,60 +84,6 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입. - `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등). - `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
### 5-1. 화면(모드)별 사용 흐름
화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지).
| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 |
|------|-----------|------------------|----------------------|
| 로그인 전 | 첫 방문 | 중앙 로그인 카드 | 디스코드 로그인. 사이드바/대기열/재생바는 렌더링하지 않는다 |
| `SERVER_LIST` | 로그인 직후, 홈 버튼, "다른 서버 선택" | 봇이 있는 내 서버 카드 목록 | 서버 선택. 서버를 고르기 전에는 검색·재생이 잠겨 있다 |
| `SERVER_DETAIL` | 서버 카드 클릭 | 서버 이름/아이콘, 사용 안내 배너, 서버 ID·내 권한 | 검색 시작점. 좌측에 조작 중인 서버 표시 |
| `SEARCH_RESULT` | 검색창에 입력 후 Enter | Spotify / YouTube Music / YouTube 영상 3개 구역의 곡 리스트 행(번호·썸네일·제목/아티스트·재생시간) | 행을 눌러 대기열에 추가 |
| 대기열 패널 | (넓은 화면) 기본 펼침, 재생바 우측 대기열 버튼으로 접기/펴기 · (좁은 화면) 기본 접힘, 우하단 초록 버튼·재생바 목록 버튼으로 열기 | "지금 재생 중" + "다음 재생 목록" | 순서 변경, 삭제, 특정 곡부터 재생 |
### 5-2. 버튼·조작 동작 정리
| 위치 | 요소 | 동작 | 비활성 조건 |
|------|------|------|-------------|
| 상단 | 홈 | 서버를 고른 상태면 서버 상세로, 아니면 서버 목록으로. 검색어 초기화 | 로그아웃 상태에선 숨김 |
| 상단 | 검색창 | Enter 로 검색 실행 → `SEARCH_RESULT` | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) |
| 상단 | 프로필/로그아웃 | 세션 종료 | — |
| 본문 | 서버 카드 | 서버 선택 → 서버 상세 | — |
| 본문 | 곡 행(행 전체가 버튼) | `POST /api/player/play` → 대기열 추가. 성공/실패를 토스트로 알림. 넓은 화면은 hover 시 번호 자리에 재생 아이콘이, 좁은 화면은 썸네일에 재생 아이콘이 항상 보인다 | 서버 미선택·미로그인 |
| 재생바 | 재생/일시정지 | `POST /api/player/pause` (낙관적 UI, 실패 시 롤백) | 봇 세션 없음/재생 중 아님 |
| 재생바 | 다음 곡 | `POST /api/player/skip` | 〃 |
| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 |
| 재생바 | 진행바 | 드래그/클릭으로 탐색 → `POST /api/player/seek`. 좁은 화면에서는 재생바 상단에 전체 폭으로 배치 | 〃 |
| 재생바 | 스피커 아이콘 | 음소거/해제 토글(해제 시 직전 볼륨 복원) | 〃 |
| 재생바 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 |
| 재생바 | 목록 아이콘(컨트롤 옆, 좁은 화면) | 대기열 슬라이드 패널 열기/닫기 | — |
| 재생바 | 목록 아이콘(우측 볼륨 옆, 넓은 화면) | 우측 대기열 컬럼 접기/펴기. 펼쳐져 있으면 초록색으로 표시 | — |
| 대기열 | 곡(제목 영역) | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다 | 첫 곡은 이미 다음 곡이라 동작 없음 |
| 대기열 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (좁은 화면 전용, 드래그 대체) | 맨 위/맨 아래 |
| 대기열 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지) | — |
| 대기열 | 드래그 핸들 | 드래그로 순서 변경(넓은 화면 전용) | — |
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 |
### 5-3. 다른 음악 서비스와 비교해 맞춘 점
스포티파이 웹 플레이어 / 유튜브 뮤직 / 애플 뮤직에서 공통으로 쓰이는 관행을 기준으로 삼았다.
| 관행 | 예전 | 현재 |
|------|------|------|
| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 |
| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) |
| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 |
| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 |
| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) |
| 스피커 아이콘 클릭 = 음소거 | 장식 아이콘 | 토글 버튼 |
| 키보드 단축키(Space/M/화살표) | 없음 | 지원 |
| 재생시간 표기 | 없음 | 검색 결과 행 오른쪽에 표시 |
| 터치 조작 크기(44px 내외) | 재생 32px·삭제 24px·진행바 1px | 44~48px, 진행바 히트영역 확대 |
| hover 없이도 조작 가능 | 삭제·재생 버튼이 hover 시에만 노출 | 상시 노출 |
미지원(봇 API 한계): 이전 곡, 반복 재생, 특정 위치로의 셔플 재생. 필요하면 봇 쪽 액션 추가가 선행돼야 한다.
--- ---
## 6. 배포 메모 ## 6. 배포 메모

View File

@@ -1,37 +1,22 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useState } from "react";
import { useSession } from "next-auth/react";
import { ListMusic, X } from "lucide-react";
import TopNav from "@/components/layout/TopNav"; import TopNav from "@/components/layout/TopNav";
import LeftSidebar from "@/components/layout/LeftSidebar"; import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent"; import MainContent from "@/components/player/MainContent";
import QueueSidebar from "@/components/player/QueueSidebar"; import QueueSidebar from "@/components/player/QueueSidebar";
import PlayerBar from "@/components/player/PlayerBar"; import PlayerBar from "@/components/player/PlayerBar";
import type { DiscordServer, Track } from "@/types/music"; import type { DiscordServer } from "@/types/music";
// 화면 모드 타입 정의 // 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() { export default function MusicPlayerLayout() {
const { status } = useSession();
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST"); const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null); const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다.
// (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다)
// · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다
// · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다
const [isQueueOpen, setIsQueueOpen] = useState(false);
const [isQueueDocked, setIsQueueDocked] = useState(true);
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
const isAuthed = status === "authenticated";
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
const showPlayback = isAuthed && !!selectedServer;
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
const handleHome = () => { const handleHome = () => {
if (selectedServer) { if (selectedServer) {
setViewMode("SERVER_DETAIL"); setViewMode("SERVER_DETAIL");
@@ -42,37 +27,28 @@ export default function MusicPlayerLayout() {
} }
}; };
// 검색 실행 시
const handleSearch = (query: string) => { const handleSearch = (query: string) => {
setSearchQuery(query); setSearchQuery(query);
setViewMode("SEARCH_RESULT"); setViewMode("SEARCH_RESULT");
}; };
// 서버 선택 시
const handleSelectServer = (server: DiscordServer) => { const handleSelectServer = (server: DiscordServer) => {
setSelectedServer(server); setSelectedServer(server);
setViewMode("SERVER_DETAIL"); setViewMode("SERVER_DETAIL");
}; };
const handleBackToServers = () => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
setIsQueueOpen(false);
};
return ( return (
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white"> <div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
<TopNav <TopNav
onSearch={handleSearch} onSearch={handleSearch}
onHome={handleHome} onHome={handleHome}
selectedServer={selectedServer} selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
/> />
<div className="relative flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */} <LeftSidebar />
{showPlayback && (
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
)}
<MainContent <MainContent
viewMode={viewMode} viewMode={viewMode}
setViewMode={setViewMode} setViewMode={setViewMode}
@@ -82,71 +58,10 @@ export default function MusicPlayerLayout() {
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
onSelectServer={handleSelectServer} onSelectServer={handleSelectServer}
/> />
<QueueSidebar selectedServer={selectedServer} />
{showPlayback && (
<>
{/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */}
{isQueueOpen && (
<button
type="button"
aria-label="대기열 닫기"
onClick={() => setIsQueueOpen(false)}
className="absolute inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
/>
)}
{/* 대기열: 데스크톱은 고정 컬럼, 모바일은 슬라이드 패널 (인스턴스는 하나만 유지) */}
<div
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
${isQueueDocked
? "lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none"
: "lg:hidden"}`}
>
<QueueSidebar
selectedServer={selectedServer}
nowPlaying={nowPlaying}
onClose={() => setIsQueueOpen(false)}
/>
</div>
</>
)}
</div> </div>
{showPlayback && ( <PlayerBar selectedServer={selectedServer} />
<PlayerBar
selectedServer={selectedServer}
onToggleQueue={() => setIsQueueOpen((v) => !v)}
isQueueOpen={isQueueOpen}
onToggleDock={() => setIsQueueDocked((v) => !v)}
isQueueDocked={isQueueDocked}
onNowPlayingChange={handleNowPlayingChange}
/>
)}
{/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */}
{showPlayback && !isQueueOpen && (
<button
type="button"
onClick={() => setIsQueueOpen(true)}
className="fixed bottom-24 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-black shadow-xl transition hover:bg-green-400 active:scale-95 lg:hidden"
aria-label="대기열 열기"
>
<ListMusic size={24} />
</button>
)}
{/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */}
{showPlayback && isQueueOpen && (
<button
type="button"
onClick={() => setIsQueueOpen(false)}
className="fixed right-4 top-20 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-neutral-800 text-white shadow-lg transition hover:bg-neutral-700 lg:hidden"
aria-label="대기열 닫기"
>
<X size={20} />
</button>
)}
</div> </div>
); );
} }

View File

@@ -1,61 +1,20 @@
"use client"; "use client";
import { ChevronLeft, Info, Server } from "lucide-react"; import { ListMusic } from "lucide-react";
import type { DiscordServer } from "@/types/music";
interface LeftSidebarProps { export default function LeftSidebar() {
selectedServer: DiscordServer | null;
onBackToServers: () => void;
}
/**
* 좌측 보조 패널. 예전에는 안내 문구만 있는 죽은 공간이었어서,
* "지금 어느 서버를 조작 중인지"와 서버 전환 동선을 넣어 실제로 쓰이게 했다.
* 좁은 화면에서는 숨긴다(같은 정보가 본문/플레이어에 이미 있다).
*/
export default function LeftSidebar({ selectedServer, onBackToServers }: LeftSidebarProps) {
return ( return (
<aside className="hidden w-60 flex-shrink-0 flex-col gap-4 border-r border-neutral-800 bg-neutral-950 p-4 lg:flex"> <aside className="w-60 bg-black p-6 flex flex-col gap-6">
<p className="px-1 text-xs font-semibold uppercase tracking-wider text-neutral-500"> <div className="flex items-center gap-2 text-neutral-400 font-medium">
조작 중인 서버 <ListMusic size={20} />
</p> <span>음악 봇 컨트롤</span>
<div className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-800">
{selectedServer?.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Server size={20} className="text-neutral-500" aria-hidden />
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">
{selectedServer?.name ?? "선택 안 됨"}
</span>
<span className="block text-xs text-neutral-500">음악 봇 연결됨</span>
</span>
</div> </div>
<button <hr className="border-neutral-800" />
onClick={onBackToServers}
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm text-neutral-300 transition-colors hover:bg-neutral-900 hover:text-white"
>
<ChevronLeft size={18} aria-hidden />
다른 서버 선택
</button>
<div className="mt-auto rounded-xl border border-neutral-800 bg-neutral-900/60 p-3"> <p className="text-xs text-neutral-500 leading-relaxed">
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-neutral-300"> 상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
<Info size={14} aria-hidden /> 사용 방법 디스코드 음성 채널에 입장한 상태에서 사용해주세요.
</p> </p>
<p className="text-xs leading-relaxed text-neutral-500">
디스코드 음성 채널에 먼저 들어간 뒤, 위 검색창에서 노래를 찾아 재생 버튼을 누르세요.
</p>
</div>
</aside> </aside>
); );
} }

View File

@@ -7,133 +7,77 @@ import type { DiscordServer } from "@/types/music";
interface TopNavProps { interface TopNavProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
onHome: () => void; onHome: () => void;
selectedServer: DiscordServer | null; selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
}
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
function Avatar({
user,
size,
}: {
user: { name?: string | null; image?: string | null };
size: "sm" | "md";
}) {
const box = size === "sm" ? "h-7 w-7 text-xs" : "h-9 w-9 text-sm";
if (user.image) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.image} alt="" className={`${box} rounded-full object-cover`} />
);
}
return (
<span
className={`${box} flex items-center justify-center rounded-full bg-neutral-700 font-semibold text-white`}
aria-hidden
>
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
</span>
);
} }
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [term, setTerm] = useState(""); const [term, setTerm] = useState("");
const isAuthed = status === "authenticated";
const canSearch = isAuthed && !!selectedServer;
const submitSearch = () => {
if (canSearch && term.trim()) onSearch(term.trim());
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") submitSearch(); if (e.key === "Enter" && term.trim()) {
onSearch(term);
}
}; };
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
const handleHomeClick = () => { const handleHomeClick = () => {
setTerm(""); setTerm(""); // 검색창 비우기
onHome(); onHome(); // 원래 홈 기능 실행
}; };
// 상황별로 검색창 안내를 다르게 준다(로그인 전인데 "서버를 고르라"고 하면 혼란스럽다).
const searchPlaceholder = !isAuthed
? "로그인하면 검색할 수 있어요"
: !selectedServer
? "먼저 서버를 선택해주세요"
: "노래 제목이나 유튜브·스포티파이 주소";
return ( return (
<header className="z-20 flex-shrink-0 border-b border-neutral-800 bg-neutral-950"> <nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
<nav className="flex h-16 items-center gap-2 px-3 sm:gap-4 sm:px-6"> {/* 1. 왼쪽: 로고 */}
{/* 로고: 좁은 화면에서는 아이콘만 남겨 검색창 폭을 확보한다. */} <div className="flex items-center gap-2 text-xl font-bold text-white w-52">
<div className="flex flex-shrink-0 items-center gap-2 text-lg font-bold"> <ListMusic className="text-green-500" />
<ListMusic className="text-green-500" aria-hidden /> <span>MusicBot</span>
<span className="hidden sm:inline">MusicBot</span>
</div> </div>
{isAuthed && ( {/* 2. 가운데: 검색창 & 홈 버튼 */}
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
<button <button
onClick={handleHomeClick} onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white" className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
aria-label="처음 화면으로" title="홈으로"
> >
<Home size={20} aria-hidden /> <Home size={22} />
</button> </button>
)}
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */} <div className="relative w-full">
<div className="relative min-w-0 flex-1"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
<Search
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500"
size={18}
aria-hidden
/>
<input <input
type="search" type="text"
value={term} value={term}
onChange={(e) => setTerm(e.target.value)} onChange={(e) => setTerm(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={!canSearch} disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
placeholder={searchPlaceholder} placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
aria-label="노래 검색" 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'}`}
className="h-11 w-full rounded-full border border-neutral-700 bg-neutral-900 pl-10 pr-4 text-sm text-white placeholder:text-neutral-500 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40 disabled:cursor-not-allowed disabled:opacity-50"
/> />
</div> </div>
</div>
{/* 계정 영역 */} {/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
<div className="flex flex-shrink-0 items-center"> <div className="flex items-center justify-end w-auto min-w-[13rem]">
{status === "loading" ? ( {status === "loading" ? (
<div className="h-11 w-11 animate-pulse rounded-full bg-neutral-800" aria-label="로그인 상태 확인 중" /> <div className="text-sm text-neutral-400">로딩 중...</div>
) : isAuthed && session?.user ? ( ) : status === "authenticated" && session?.user ? (
<div className="flex items-center gap-1 sm:gap-2"> <div className="flex items-center gap-3">
<span className="hidden items-center gap-2 rounded-full border border-neutral-700 bg-neutral-900 py-1 pl-1 pr-3 md:flex"> <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">
<Avatar user={session.user} size="sm" /> <img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
<span className="max-w-[10rem] truncate text-sm font-medium">{session.user.name}</span> <span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
</span> </div>
<span className="md:hidden"> <button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
<Avatar user={session.user} size="md" /> <LogOut size={18} />
</span>
<button
onClick={() => signOut()}
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-red-400"
aria-label="로그아웃"
title="로그아웃"
>
<LogOut size={18} aria-hidden />
</button> </button>
</div> </div>
) : ( ) : (
<button <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">
onClick={() => signIn("discord")} <LogIn size={16} /> Discord 로그인
className="flex h-11 items-center gap-2 rounded-full bg-[#5865F2] px-4 text-sm font-medium text-white shadow-md transition-colors hover:bg-[#4752C4] sm:px-5"
>
<LogIn size={16} aria-hidden />
<span className="hidden sm:inline">Discord 로그인</span>
<span className="sm:hidden">로그인</span>
</button> </button>
)} )}
</div> </div>
</nav> </nav>
</header>
); );
} }

View File

@@ -1,19 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { signIn, useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react"; import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
import { ViewMode } from "@/app/page"; import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기 // 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider"; import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music"; import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
/** ms -> m:ss (검색 결과 재생시간 표기용) */
function formatDuration(ms?: number) {
if (!ms || Number.isNaN(ms)) return "";
const total = Math.floor(ms / 1000);
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
}
interface MainContentProps { interface MainContentProps {
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
@@ -164,129 +157,67 @@ export default function MainContent({
if (status === "unauthenticated") { if (status === "unauthenticated") {
return ( return (
<main className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center"> <main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800"> <Server size={48} className="text-neutral-500 mb-4" />
<ListMusic size={32} className="text-green-500" aria-hidden /> <h2 className="text-2xl font-bold">로그인이 필요합니다</h2>
</span> <p className="text-neutral-400">서비스를 이용하시려면 디스코드 로그인을 해주세요.</p>
<div>
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
</p>
</div>
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
<button
onClick={() => signIn("discord")}
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
>
<LogIn size={18} aria-hidden />
Discord 로그인
</button>
</main> </main>
); );
} }
if (status === "loading") { if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
return (
<main className="flex flex-1 items-center justify-center bg-neutral-900">
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
</main>
);
}
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0; const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
/** const renderTrackCard = (track: SearchTrack) => (
* 곡 하나는 '리스트 행'으로 보여준다. <div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
* 그리드 카드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태고, <div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
* 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(제목·아티스트·재생시간) 형식이라 {track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
* 사용자에게 익숙한 쪽으로 맞춘다. 행 전체가 버튼이라 터치로도 바로 누를 수 있다.
*/
const renderTrackRow = (track: SearchTrack, index: number) => (
<button <button
key={track.videoId || track.id || track.url || track.title} onClick={(e) => {
type="button" e.stopPropagation();
onClick={() => handleMusicAction('player_play', track)} handleMusicAction('player_play', track);
aria-label={`${track.title} 재생목록에 추가`} }}
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:bg-neutral-700/60" className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
title="바로 재생"
> >
{/* 번호 자리에 hover 시 재생 아이콘이 겹치는 건 스포티파이 곡 목록과 같은 방식 */} <Play fill="black" stroke="black" size={16} />
<span className="hidden w-5 flex-shrink-0 text-center text-sm tabular-nums text-neutral-500 group-hover:hidden sm:block">
{index + 1}
</span>
<span className="hidden w-5 flex-shrink-0 items-center justify-center text-white sm:group-hover:flex">
<Play fill="currentColor" size={14} aria-hidden />
</span>
<span className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded bg-neutral-700">
{track.thumbnail && (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
)}
{/* 좁은 화면은 hover 가 없으니 재생 아이콘을 항상 얹어 눌러야 할 곳을 알려준다. */}
<span className="absolute inset-0 flex items-center justify-center bg-black/40 sm:hidden">
<Play fill="white" stroke="white" size={16} aria-hidden />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
</span>
{track.duration ? (
<span className="flex-shrink-0 text-xs tabular-nums text-neutral-500">
{formatDuration(track.duration)}
</span>
) : null}
</button> </button>
</div>
<h3 className="font-medium text-white text-sm truncate">{track.title}</h3>
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
</div>
); );
return ( return (
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8"> <main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
{/* 화면 1: 서버 목록 */} {/* 화면 1: 서버 목록 */}
{viewMode === "SERVER_LIST" && ( {viewMode === "SERVER_LIST" && (
<> <>
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2> <h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p>
{isFetching && servers.length === 0 ? ( {isFetching && servers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400"> <div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden /> <Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p>서버 목록을 불러오는 중...</p> <p>서버 목록을 불러오는 중...</p>
</div> </div>
) : servers.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500">
<Server size={40} className="opacity-50" aria-hidden />
<p className="text-sm">표시할 서버가 없습니다.</p>
<p className="max-w-sm text-xs text-neutral-600">
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
</p>
</div>
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{servers.map((server) => ( {servers.map((server) => (
<button <div
key={server.id} key={server.id}
type="button"
onClick={() => onSelectServer(server)} onClick={() => onSelectServer(server)}
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]" className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md w-full"
> >
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg"> <div className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
{server.icon ? ( {server.icon ? (
// eslint-disable-next-line @next/next/no-img-element <img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
<img
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`}
className="h-full w-full object-cover"
alt=""
/>
) : ( ) : (
<Server size={24} className="text-neutral-500" aria-hidden /> <Server size={24} className="text-neutral-500" />
)} )}
</span> </div>
<span className="block truncate text-center text-sm font-medium">{server.name}</span> <h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
</button> </div>
))} ))}
</div> </div>
)} )}
@@ -305,45 +236,29 @@ export default function MainContent({
서버 목록으로 돌아가기 서버 목록으로 돌아가기
</button> </button>
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left"> <div className="flex items-end gap-6 mb-8">
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40"> <div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
{selectedServer.icon ? ( {selectedServer.icon ? (
// eslint-disable-next-line @next/next/no-img-element <img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
<img ) : <Server size={64} className="text-neutral-500" />}
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} </div>
className="h-full w-full object-cover" <div>
alt="" <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>
) : <Server size={56} className="text-neutral-500" aria-hidden />} <p className="text-neutral-400">이 서버에서 음악 봇이 활발하게 작동 중입니다.</p>
</span>
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
<p className="mt-2 text-sm text-neutral-400">
위 검색창에서 노래를 찾아 바로 재생할 수 있습니다.
</p>
</div> </div>
</div> </div>
{/* 무엇을 해야 하는지 알려주는 안내 — 서버만 고르면 다음 행동이 막막했던 문제 보완 */} <div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
<div className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4"> <h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> 상세 정보</h3>
<p className="text-sm text-green-200"> <div className="grid grid-cols-2 gap-4 text-sm">
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요. <div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
</p> <p className="text-neutral-500 mb-1">서버 고유 ID</p>
<p className="font-mono text-white">{selectedServer.id}</p>
</div> </div>
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6"> <p className="text-neutral-500 mb-1">나의 서버 권한</p>
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg"> <p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
<Music size={20} aria-hidden /> 상세 정보
</h3>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
<p className="break-all font-mono text-white">{selectedServer.id}</p>
</div>
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">나의 서버 권한</p>
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -353,8 +268,8 @@ export default function MainContent({
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */} {/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
{viewMode === "SEARCH_RESULT" && ( {viewMode === "SEARCH_RESULT" && (
<div> <div>
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">&ldquo;{searchQuery}&rdquo; 검색 결과</h2> <h2 className="text-2xl font-bold mb-2">&ldquo;{searchQuery}&rdquo; 검색 결과</h2>
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p> <p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
{isSearching ? ( {isSearching ? (
<div className="flex flex-col items-center py-20"> <div className="flex flex-col items-center py-20">
@@ -375,8 +290,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400"> <h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
<Disc size={24} /> Spotify 결과 <Disc size={24} /> Spotify 결과
</h3> </h3>
<div className="flex flex-col"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.spotify.map(renderTrackRow)} {searchResults.spotify.map(renderTrackCard)}
</div> </div>
</section> </section>
)} )}
@@ -387,8 +302,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500"> <h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
<Music size={24} /> YouTube Music <Music size={24} /> YouTube Music
</h3> </h3>
<div className="flex flex-col"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeMusic.map(renderTrackRow)} {searchResults.youtubeMusic.map(renderTrackCard)}
</div> </div>
</section> </section>
)} )}
@@ -399,8 +314,8 @@ export default function MainContent({
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white"> <h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상 <MonitorPlay size={24} className="text-red-600" /> YouTube 영상
</h3> </h3>
<div className="flex flex-col"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeVideo.map(renderTrackRow)} {searchResults.youtubeVideo.map(renderTrackCard)}
</div> </div>
</section> </section>
)} )}

View File

@@ -1,21 +1,14 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play, ListMusic } from "lucide-react"; import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import type { DiscordServer, Track } from "@/types/music"; import type { DiscordServer, Track } from "@/types/music";
interface PlayerBarProps { interface PlayerBarProps {
selectedServer: DiscordServer | null; selectedServer: DiscordServer | null;
onToggleQueue?: () => void;
isQueueOpen?: boolean;
/** 넓은 화면에서 우측 대기열 컬럼을 접었다 펴는 토글 */
onToggleDock?: () => void;
isQueueDocked?: boolean;
/** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */
onNowPlayingChange?: (track: Track | null) => void;
} }
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onToggleDock, isQueueDocked, onNowPlayingChange }: PlayerBarProps) { export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const { data: session } = useSession(); const { data: session } = useSession();
// 재생 상태 관리 // 재생 상태 관리
@@ -34,7 +27,6 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
// eslint-disable-next-line react-hooks/purity // eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() }); const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용) const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적 // 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false); const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
@@ -247,40 +239,6 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
} }
}; };
/** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */
const pushVolume = async (next: number, rollbackTo: number) => {
if (!selectedServer) return;
try {
const res = await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id, volume: next }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) setVolume(rollbackTo);
} catch (error) {
console.error("볼륨 조절 에러:", error);
setVolume(rollbackTo);
}
};
/**
* 스피커 아이콘 클릭 = 음소거/해제.
* 스포티파이·유튜브뮤직·애플뮤직 모두 같은 동작이라, 아이콘을 장식으로 두지 않고 버튼으로 만든다.
*/
const handleToggleMute = async () => {
if (!canControl) return;
if (volume > 0) {
mutedVolumeRef.current = volume; // 해제할 때 돌아갈 값 기억
setVolume(0);
await pushVolume(0, mutedVolumeRef.current);
} else {
const restore = mutedVolumeRef.current || 50;
setVolume(restore);
await pushVolume(restore, 0);
}
};
// 시간 포맷 변환 함수 (ms -> mm:ss) // 시간 포맷 변환 함수 (ms -> mm:ss)
const formatTime = (ms: number) => { const formatTime = (ms: number) => {
if (!ms || isNaN(ms)) return "00:00"; if (!ms || isNaN(ms)) return "00:00";
@@ -293,243 +251,136 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
// 진행도 퍼센트 계산 (0 ~ 100) // 진행도 퍼센트 계산 (0 ~ 100)
const progressPercent = duration > 0 ? (position / duration) * 100 : 0; const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
const canControl = !!botPlayer && !!isPlaying && !!track;
// 현재 곡이 바뀌면 상위로 알려 대기열 패널의 "지금 재생 중"과 동기화한다.
useEffect(() => {
onNowPlayingChange?.(track);
}, [track, onNowPlayingChange]);
/**
* 웹 음악 플레이어 표준 단축키(스포티파이/유튜브뮤직 공통):
* Space 재생·일시정지 / M 음소거 / ← → 5초 탐색 / Ctrl+→ 다음 곡
* 검색창처럼 글자를 입력하는 곳에 포커스가 있으면 가로채지 않는다.
*/
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
const typing =
el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable;
if (typing || !canControl) return;
if (e.code === "Space") {
e.preventDefault();
handleTogglePause();
} else if (e.key === "m" || e.key === "M" || e.key === "ㅡ") {
e.preventDefault();
handleToggleMute();
} else if (e.key === "ArrowRight" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSkip();
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const delta = e.key === "ArrowRight" ? 5000 : -5000;
const next = Math.min(Math.max(position + delta, 0), duration || 0);
setPosition(next);
anchorRef.current = { pos: next, at: Date.now() };
if (selectedServer) {
fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id, seek: next }),
}).catch((err) => console.error("시간 이동 에러:", err));
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
return ( return (
<footer className="z-50 flex-shrink-0 border-t border-neutral-800 bg-neutral-950"> <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-2 px-3 pt-2 text-[11px] tabular-nums text-neutral-400 sm:hidden">
<span className="w-9 text-right">{formatTime(position)}</span>
<div className="group relative flex h-6 flex-1 items-center">
<input
type="range"
min={0}
max={duration || 100}
value={position}
disabled={!canControl}
aria-label="재생 위치"
onChange={(e) => {
isDragging.current = true;
setPosition(Number(e.target.value));
}}
onMouseUp={handleSeekEnd}
onTouchEnd={handleSeekEnd}
className="absolute z-20 h-6 w-full cursor-pointer opacity-0"
/>
<div className="pointer-events-none absolute z-10 h-1.5 w-full overflow-hidden rounded-full bg-neutral-700">
<div className="h-full bg-green-500" style={{ width: `${progressPercent}%` }} />
</div>
</div>
<span className="w-9">{formatTime(duration)}</span>
</div>
<div className="flex h-20 items-center justify-between gap-3 px-3 sm:h-24 sm:px-6"> {/* 1. 노래 정보 영역 */}
{/* 1. 현재 곡 */} <div className="flex items-center gap-4 w-1/4">
<div className="flex min-w-0 flex-1 items-center gap-3 sm:w-1/4 sm:flex-none">
<span className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-md bg-neutral-800 sm:h-14 sm:w-14">
{track?.info?.artworkUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.info.artworkUrl} alt="" className="h-full w-full object-cover" />
)}
</span>
<span className="min-w-0 flex-1">
{track ? ( {track ? (
<> <>
<span className="block truncate text-sm font-semibold text-white">{track.info?.title}</span> <div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
<span className="block truncate text-xs text-neutral-400"> {track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
{track.info?.author?.replace(" - Topic", "")} </div>
</span> <div className="flex flex-col overflow-hidden">
<span className="text-sm font-bold text-white truncate cursor-pointer hover:underline">{track.info?.title}</span>
<span className="text-xs text-neutral-400 truncate cursor-pointer hover:underline">{track.info?.author?.replace(" - Topic", "")}</span>
</div>
</> </>
) : ( ) : (
<> // 재생 중인 곡이 없을 때 빈 공간
<span className="block text-sm text-neutral-400">재생 중인 곡 없음</span> <div className="flex items-center gap-4 text-neutral-600">
<span className="hidden text-xs text-neutral-600 sm:block">검색해서 노래를 추가해보세요</span> <div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
</> <span className="text-sm">재생 중인 곡 없음</span>
</div>
)} )}
</span>
</div> </div>
{/* 2. 컨트롤 (모바일에서는 버튼만, 진행바는 위쪽에 따로) */} {/* 2. 중앙 컨트롤러 영역 */}
<div className="flex flex-shrink-0 flex-col items-center gap-1 sm:w-2/4 sm:max-w-2xl sm:flex-1"> <div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
<div className="flex items-center gap-1 sm:gap-4"> <div className="flex items-center gap-6">
<button {/* 이전 곡 (비활성화 상태) */}
disabled <button className="text-neutral-600 cursor-not-allowed">
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다" <SkipBack size={20} />
aria-label="이전 곡 (지원 예정)"
className="hidden h-11 w-11 cursor-not-allowed items-center justify-center rounded-full text-neutral-700 sm:flex"
>
<SkipBack size={20} aria-hidden />
</button> </button>
{/* 재생/일시정지 버튼 */}
<button <button
onClick={handleTogglePause} onClick={handleTogglePause}
disabled={!canControl} disabled={!botPlayer || !isPlaying || !track}
aria-label={isPaused ? "재생" : "일시정지"} className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
className={`flex h-12 w-12 items-center justify-center rounded-full text-black transition ${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
${canControl ? "bg-white hover:scale-105 active:scale-95" : "cursor-not-allowed bg-neutral-700 text-neutral-500"}`}
> >
{!isPaused ? <Pause size={22} fill="currentColor" aria-hidden /> : <Play size={22} fill="currentColor" className="ml-0.5" aria-hidden />} {!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
</button> </button>
{/* 다음 곡 스킵 버튼 */}
<button <button
onClick={handleSkip} onClick={handleSkip}
disabled={!canControl} disabled={!botPlayer || !isPlaying || !track}
aria-label="다음 곡" className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
className={`flex h-11 w-11 items-center justify-center rounded-full transition-colors
${canControl ? "text-neutral-300 hover:bg-neutral-800 hover:text-white" : "cursor-not-allowed text-neutral-700"}`}
> >
<SkipForward size={20} aria-hidden /> <SkipForward size={20} />
</button> </button>
{/* 좁은 화면에서 대기열 열기(넓은 화면에는 우측 하단에 같은 버튼을 따로 둔다) */}
{onToggleQueue && (
<button
onClick={onToggleQueue}
aria-label={isQueueOpen ? "대기열 닫기" : "대기열 열기"}
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 hover:text-white lg:hidden"
>
<ListMusic size={20} aria-hidden />
</button>
)}
</div> </div>
{/* 데스크톱 진행바 */} {/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
<div className="hidden w-full items-center gap-2 text-xs tabular-nums text-neutral-400 sm:flex"> <div className="flex items-center gap-2 w-full text-xs text-neutral-400">
<span className="w-10 text-right">{formatTime(position)}</span> <span className="w-10 text-right">{formatTime(position)}</span>
<div className="group relative flex h-4 flex-1 items-center">
<div className="flex-1 flex items-center group relative h-4">
<input <input
type="range" type="range"
min={0} min={0}
max={duration || 100} max={duration || 100}
value={position} value={position}
disabled={!canControl} disabled={!botPlayer || !isPlaying || !track}
aria-label="재생 위치"
onChange={(e) => { onChange={(e) => {
isDragging.current = true; isDragging.current = true;
setPosition(Number(e.target.value)); setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트
}} }}
onMouseUp={handleSeekEnd} onMouseUp={handleSeekEnd}
onTouchEnd={handleSeekEnd} onTouchEnd={handleSeekEnd}
className="absolute z-20 h-4 w-full cursor-pointer opacity-0" className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
// ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
/> />
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700"> {/* 시각적으로 보여주는 커스텀 바 */}
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
<div <div
className="h-full bg-white transition-colors group-hover:bg-green-500" className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
style={{ width: `${progressPercent}%` }} style={{ width: `${progressPercent}%` }}
/> ></div>
</div> </div>
{/* 동그란 핸들바 */}
<div <div
className="pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100" className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5"
style={{ left: `${progressPercent}%` }} style={{ left: `${progressPercent}%` }}
/> ></div>
</div> </div>
<span className="w-10">{formatTime(duration)}</span> <span className="w-10">{formatTime(duration)}</span>
</div> </div>
</div> </div>
{/* 3. 우측 보조 조작: 대기열 + 볼륨 (스포티파이도 이 자리에 대기열 버튼을 둔다) */} {/* 3. 우측 볼륨 영역 */}
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex"> <div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
{onToggleDock && ( {volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
<button
onClick={onToggleDock}
aria-label={isQueueDocked ? "대기열 접기" : "대기열 펼치기"}
title="대기열"
className={`hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white lg:flex
${isQueueDocked ? "text-green-500" : ""}`}
>
<ListMusic size={20} aria-hidden />
</button>
)}
<button
onClick={handleToggleMute}
disabled={!canControl}
aria-label={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
title={volume === 0 ? "음소거 해제 (M)" : "음소거 (M)"}
className="flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{volume === 0 ? <VolumeX size={20} aria-hidden /> : <Volume2 size={20} aria-hidden />}
</button>
<div className="group relative flex h-8 w-24 items-center"> <div className="w-24 h-4 flex items-center group relative">
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={volume} value={volume}
disabled={!canControl} disabled={!botPlayer || !isPlaying || !track}
aria-label="볼륨" onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
aria-valuetext={`${volume}%`}
onChange={handleVolumeChange}
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }} onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }} onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onMouseUp={handleVolumeEnd} onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
onTouchEnd={handleVolumeEnd} onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
className="absolute z-20 h-8 w-full cursor-pointer opacity-0" className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
/> />
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700"> {/* 커스텀 볼륨 바 디자인 */}
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
<div <div
className="h-full bg-white transition-colors group-hover:bg-green-500" className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
style={{ width: `${volume}%` }} style={{ width: `${volume}%` }}
/> ></div>
</div> </div>
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
<div <div
className="pointer-events-none absolute z-10 -ml-1.5 flex h-3 w-3 justify-center rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100" className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5 flex justify-center"
style={{ left: `${volume}%` }} style={{ left: `${volume}%` }}
> >
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
{isVolumeDragging && ( {isVolumeDragging && (
<span className="absolute bottom-4 whitespace-nowrap rounded border border-neutral-700 bg-neutral-800 px-2 py-1 text-[10px] font-bold text-white shadow-lg"> <div className="absolute bottom-4 bg-neutral-800 text-white text-[10px] font-bold py-1 px-2 rounded shadow-lg border border-neutral-700 whitespace-nowrap animate-in fade-in zoom-in duration-150">
{volume} {volume}
</span> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
</footer> </footer>
); );
} }

View File

@@ -1,18 +1,18 @@
"use client"; "use client";
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music, ChevronUp, ChevronDown } from "lucide-react"; import { Trash2, GripVertical, Music } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider"; import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music"; import type { DiscordServer, Track } from "@/types/music";
interface QueueSidebarProps { interface QueueSidebarProps {
selectedServer: DiscordServer | null; selectedServer: DiscordServer | null;
nowPlaying?: Track | null;
onClose?: () => void;
} }
export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSidebarProps) { export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
// 👇 [추가] 토스트 사용 준비 완료!
const { showToast } = useToast(); const { showToast } = useToast();
const [queue, setQueue] = useState<Track[]>([]); const [queue, setQueue] = useState<Track[]>([]);
@@ -80,104 +80,62 @@ export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSideba
}; };
}, [selectedServer, status, fetchQueue]); }, [selectedServer, status, fetchQueue]);
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */ // 🌟 [수정됨] 드래그 앤 드롭 정렬 함수
const applyReorder = async (newQueue: Track[]) => { const handleSort = async () => {
if (!selectedServer) return; const fromIndex = dragItem.current;
const previous = queue; const toIndex = dragOverItem.current;
// 1. 마우스를 떼는 즉시 UI 애니메이션을 원래대로 되돌립니다.
setDraggingIndex(null);
setDragOverIndex(null);
dragItem.current = null;
dragOverItem.current = null;
// 드래그 위치가 없거나 서버가 없으면 종료
if (fromIndex === null || toIndex === null || !selectedServer) {
return;
}
// 2. 실제 노래가 삽입될 최종 인덱스 계산
let insertIndex = toIndex;
if (fromIndex < toIndex && toIndex !== queue.length) {
insertIndex = toIndex - 1;
}
// 🌟 [신규 추가] 결과적으로 순서가 바뀌지 않았다면 여기서 바로 함수를 종료합니다! (API 요청 안 함)
if (fromIndex === insertIndex) {
console.log("순서가 변경되지 않아서 통신을 건너뜁니다.");
return;
}
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
const userId = session?.user?.id;
// 3. 화면 즉시 업데이트
const newQueue = [...queue];
const draggedItem = newQueue.splice(fromIndex, 1)[0];
newQueue.splice(insertIndex, 0, draggedItem);
setQueue(newQueue); setQueue(newQueue);
// 4. 서버(API)에 변경된 배열 전송
try { try {
const res = await fetch('/api/queue/set', { const res = await fetch('/api/queue/set', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
serverId: selectedServer.id, serverId: selectedServer.id,
userId: session?.user?.id, userId: userId,
newQueue, newQueue: newQueue
}) })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.success) { if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error"); showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
setQueue(previous); fetchQueue(); // 실패 시 원래 큐로 복구
fetchQueue();
} }
} catch (error) { } catch (error) {
console.error("순서 동기화 실패:", error); console.error("순서 동기화 실패:", error);
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error"); showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
setQueue(previous);
fetchQueue();
}
};
const handleSort = async () => {
const fromIndex = dragItem.current;
const toIndex = dragOverItem.current;
setDraggingIndex(null);
setDragOverIndex(null);
dragItem.current = null;
dragOverItem.current = null;
if (fromIndex === null || toIndex === null || !selectedServer) return;
let insertIndex = toIndex;
if (fromIndex < toIndex && toIndex !== queue.length) {
insertIndex = toIndex - 1;
}
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
const newQueue = [...queue];
const draggedItem = newQueue.splice(fromIndex, 1)[0];
newQueue.splice(insertIndex, 0, draggedItem);
await applyReorder(newQueue);
};
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
const moveItem = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= queue.length) return;
const newQueue = [...queue];
[newQueue[index], newQueue[target]] = [newQueue[target], newQueue[index]];
await applyReorder(newQueue);
};
/**
* 대기열의 곡을 누르면 그 곡부터 재생한다(스포티파이·유튜브뮤직 대기열과 같은 동작).
* 봇 API 에 "특정 곡으로 점프"가 없어서, 대기열을 대상 곡부터로 바꾼 뒤 다음 곡으로 넘긴다.
* 결과적으로 건너뛴 곡들은 대기열에서 빠진다 — 이것도 위 서비스들과 동일하다.
*/
const playFrom = async (index: number) => {
if (!selectedServer || index <= 0) return;
const rest = queue.slice(index);
const previous = queue;
setQueue(rest);
try {
const setRes = await fetch('/api/queue/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue: rest,
})
});
const setData = await setRes.json();
if (!setRes.ok || !setData.success) {
showToast(setData.message || setData.error || "곡을 건너뛰지 못했습니다.", "error");
setQueue(previous);
return;
}
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id }),
});
} catch (error) {
console.error("곡 점프 실패:", error);
showToast("곡을 건너뛰지 못했습니다.", "error");
setQueue(previous);
fetchQueue(); fetchQueue();
} }
}; };
@@ -213,61 +171,26 @@ export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSideba
}; };
return ( return (
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800"> <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>
{nowPlaying && (
<div className="mb-4 pr-12 lg:pr-0">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-green-500">
지금 재생 중
</p>
<div className="flex items-center gap-3 rounded-lg border border-green-500/30 bg-green-500/10 p-2">
<span className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
{nowPlaying.info?.artworkUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={nowPlaying.info.artworkUrl} alt="" className="h-full w-full object-cover" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">
{nowPlaying.info?.title || "제목 없음"}
</span>
<span className="block truncate text-xs text-neutral-400">
{nowPlaying.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
</span>
</span>
</div>
</div>
)}
<div className={`mb-3 flex items-center justify-between gap-2 ${nowPlaying ? "" : "pr-12 lg:pr-0"}`}>
<h2 className="text-base font-bold">다음 재생 목록</h2>
{queue.length > 0 && (
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
{queue.length}곡
</span>
)}
</div>
{queue.length === 0 ? ( {queue.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-neutral-500"> <div className="flex flex-col items-center justify-center text-neutral-500 h-full gap-2">
<Music size={32} className="opacity-50" aria-hidden /> <Music size={32} className="opacity-50" />
<p className="text-sm">대기 중인 노래가 없습니다.</p> <p className="text-sm">대기열에 노래가 없습니다.</p>
<p className="text-xs text-neutral-600">
검색해서 나온 곡의 재생 버튼을 누르면 여기에 쌓입니다.
</p>
</div> </div>
) : ( ) : (
<ul className="flex flex-col gap-1 overflow-y-auto pb-24 pr-1"> <div className="flex flex-col overflow-y-auto pr-2 pb-10">
{queue.map((item, index) => { {queue.map((item, index) => {
const isDropTarget = dragOverIndex === index && draggingIndex !== index; const isDropTarget = dragOverIndex === index && draggingIndex !== index;
const title = item.info?.title || "제목 없음";
return ( return (
<li <div
key={(item.encoded as string) || `${index}-${title}`} key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
draggable draggable
onDragStart={() => { onDragStart={() => {
dragItem.current = index; dragItem.current = index;
// 🌟 마우스 잔상(고스트)이 까맣게 캡처되는 것을 막기 위한 0초 딜레이 트릭
setTimeout(() => setDraggingIndex(index), 0); setTimeout(() => setDraggingIndex(index), 0);
}} }}
onDragEnter={() => { onDragEnter={() => {
@@ -276,89 +199,67 @@ export default function QueueSidebar({ selectedServer, nowPlaying }: QueueSideba
}} }}
onDragEnd={handleSort} onDragEnd={handleSort}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
className={`relative flex items-center gap-2 rounded-lg border border-transparent bg-neutral-900/60 p-2 transition-colors hover:border-neutral-700 className={`relative flex items-center gap-3 group bg-neutral-900/50 p-2 rounded-md transition-all duration-200 ease-in-out cursor-grab active:cursor-grabbing border border-transparent hover:border-neutral-700 my-1
${draggingIndex === index ? "opacity-40" : "opacity-100"} ${draggingIndex === index ? 'opacity-30 scale-95 shadow-inner' : 'opacity-100'}
${isDropTarget ? "border-green-500/70" : ""}`} ${isDropTarget ? 'mt-10' : 'mt-1'}
`}
> >
{/* 드래그 핸들: 마우스 환경에서만 의미가 있어 큰 화면에서만 노출 */} {/* 초록색 드롭 인디케이터 선 */}
<span className="hidden cursor-grab text-neutral-600 active:cursor-grabbing lg:block" aria-hidden> {isDropTarget && (
<div className="absolute -top-5 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
)}
<div className="text-neutral-600 group-hover:text-neutral-400">
<GripVertical size={16} /> <GripVertical size={16} />
</span> </div>
{/* 곡을 누르면 그 곡부터 재생 — 다른 음악 서비스의 대기열과 같은 조작감 */} <div className="w-10 h-10 bg-neutral-700 rounded flex-shrink-0 overflow-hidden shadow-md">
<button
type="button"
onClick={() => playFrom(index)}
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left"
aria-label={`${title} 부터 재생`}
title="이 곡부터 재생"
>
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
{index + 1}
</span>
<span className="relative h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
{item.info?.artworkUrl ? ( {item.info?.artworkUrl ? (
// eslint-disable-next-line @next/next/no-img-element <img src={item.info.artworkUrl} alt="" className="w-full h-full object-cover" />
<img src={item.info.artworkUrl} alt="" className="h-full w-full object-cover" />
) : null} ) : null}
</span> </div>
<span className="min-w-0 flex-1"> <div className="flex flex-col overflow-hidden flex-1 pointer-events-none">
<span className="block truncate text-sm font-medium text-white">{title}</span> <span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
<span className="block truncate text-xs text-neutral-400"> {item.info?.title || "제목 없음"}
</span>
<span className="text-xs text-neutral-400 truncate">
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"} {item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
</span> </span>
</span> </div>
</button>
{/* 조작 버튼: hover 로 숨기지 않는다(터치 기기에서는 hover 가 없어 아예 못 쓴다).
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
<span className="flex flex-shrink-0 items-center">
<button <button
onClick={() => moveItem(index, -1)} onClick={(e) => {
disabled={index === 0} e.stopPropagation();
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden" handleDelete(index);
aria-label={`${title} 위로 옮기기`} }}
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-500 transition-all p-1"
title="대기열에서 삭제"
> >
<ChevronUp size={16} aria-hidden /> <Trash2 size={16} />
</button> </button>
<button </div>
onClick={() => moveItem(index, 1)}
disabled={index === queue.length - 1}
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
aria-label={`${title} 아래로 옮기기`}
>
<ChevronDown size={16} aria-hidden />
</button>
<button
onClick={() => handleDelete(index)}
className="flex h-9 w-9 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-red-400"
aria-label={`${title} 대기열에서 빼기`}
>
<Trash2 size={16} aria-hidden />
</button>
</span>
</li>
); );
})} })}
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */} {/* 🌟 [신규 추가] 맨 아래로 드래그하기 위한 전용 투명 드롭존 */}
{draggingIndex !== null && ( {draggingIndex !== null && (
<li <div
onDragEnter={() => { onDragEnter={() => {
dragOverItem.current = queue.length; dragOverItem.current = queue.length;
setDragOverIndex(queue.length); setDragOverIndex(queue.length);
}} }}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
className="hidden h-10 items-center justify-center lg:flex" className={`relative h-12 flex items-center justify-center transition-all ${dragOverIndex === queue.length ? 'mt-4' : 'mt-0'
}`}
> >
{dragOverIndex === queue.length && ( {dragOverIndex === queue.length && (
<span className="h-0.5 w-full rounded-full bg-green-500" /> <div className="absolute top-1/2 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
)} )}
</li> </div>
)} )}
</ul>
</div>
)} )}
</aside> </aside>
); );