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
54 changed files with 1499 additions and 4316 deletions

View File

@@ -10,7 +10,6 @@
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
| [`soloist/`](soloist) | `soloist-bridge` — 스포티파이 곡을 스포티파이 공식 헤드리스 클라이언트(Soloist)로 재생해 MP3 스트림으로 Lavalink에 넘기는 중계 서버(docker). 안 되면 봇이 유튜브로 재생. | [soloist/README.md](soloist/README.md) |
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
## 전체 아키텍처

View File

@@ -34,10 +34,6 @@ YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
YTRESOLVER_URL=http://192.168.10.5:8779
# --- 스포티파이 Soloist 중계 서버 (선택, 리포 루트 soloist/) ---
# 스포티파이 곡을 Soloist로 먼저 재생하고, 안 되면 유튜브로 재생한다. 비우면 유튜브로만 재생.
SOLOIST_URL=http://192.168.10.5:8780
# --- Redis (대시보드 상태 연동, 선택) ---
REDIS=true
REDIS_HOST=192.168.10.7

View File

@@ -100,7 +100,6 @@ sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
| `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) |
| `SOLOIST_URL` | (선택) 스포티파이 Soloist 중계 서버 주소(예 `http://192.168.10.5:8780`). 비우면 스포티파이 곡도 유튜브로만 재생. [`../soloist/README.md`](../soloist/README.md) |
| `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 제어/상태 연동(선택) |
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다.
@@ -143,16 +142,6 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가.
- 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
- 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
- 스포티파이 재생목록/앨범(최대 **50,000곡**, `SpotifyCollectionMax`): 웹 플레이어 partner API(익명 토큰은 토크너 `SPOTIFY_TOKENER_URL`)로
첫 100곡만 받아 **곡 정보만** 곧바로 대기열에 넣고 재생을 시작한다. 나머지는 1000곡씩(동시 3페이지) 백그라운드로 이어 붙인다
(`LavalinkManager.appendSpotifyPages`, 1만 곡 기준 ~6초). 정지·모두 지우기·퇴장하면 `queueEpoch`가 바뀌어 이어 받기를 멈춘다.
`-s`면 새 페이지가 올 때마다 그 재생목록 곡끼리 다시 섞는다. partner API가 실패하면 공개 임베드 페이지(100곡), 그것도 실패하면 Lavalink(LavaSrc).
partner API의 persisted query 해시(`Spotify.ts`의 `PARTNER_QUERY`)는 스포티파이가 바꾸면 갱신해야 한다.
유튜브 원곡 매칭은 곡마다 **재생 직전**에 한다(`GuildPlayer.resolvePlayable` → `LavalinkManager.matchSpotifyTrack`).
다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다.
- 대시보드 `queue_list`는 대기열 앞 200곡과 전체 곡 수(`total`)·총 길이(`totalMs`)만 보낸다. `queue_set`은 그 앞부분(`windowSize`)만 바꾸고
뒤는 그대로 두며, `clearAll`이면 전부 비운다.
스포티파이 음원 자체를 직접 재생하지는 않는다(LavaSrc도 항상 다른 음원으로 미러링하며, 직접 재생은 Premium 계정 + librespot이 필요).
- 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).
### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임)
@@ -170,9 +159,7 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
사이트는 그 키를 짧게 폴링해 결과를 받는다.
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`,
`queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음).
- `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다.
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.
@@ -203,7 +190,6 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
- **유튜브뮤직 검색 품질**: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 `ytsearch:… Topic`(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 `-p`(스포티파이 우선)를 쓴다.
- **스포티파이 곡이 유튜브로만 재생됨**: 봇 로그의 `[Soloist] 재생 준비 실패(...)` 사유 확인. `not_paired`=페어링 필요, `busy`=다른 길드가 사용 중(정상), `disabled`=API 키 없음. `curl http://192.168.10.5:8780/health`.
- **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수).
---

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

@@ -9,8 +9,6 @@ import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config";
import { Spotify } from "../utils/api/Spotify";
import { Soloist } from "../utils/api/Soloist";
const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10;
@@ -22,8 +20,7 @@ const LoadFailRetryDelayMs = 2500;
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
// _soloist: 지금 Soloist(스포티파이 공식 클라이언트) 스트림으로 재생 중인지. 이 스트림은 위치 이동이 안 된다.
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; _soloist?: boolean; };
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
export class GuildPlayer {
private isDead = false;
@@ -31,8 +28,6 @@ export class GuildPlayer {
private _GDB: GuildType | undefined;
private lastPlayedTrack: QueueTrack | undefined;
public queue: QueueTrack[] = [];
/** 대기열을 통째로 비울 때(정지·끝·모두 지우기)마다 1 증가. 백그라운드로 재생목록을 이어 받는 작업이 중단 여부를 판단한다. */
public queueEpoch = 0;
private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined;
@@ -40,8 +35,6 @@ export class GuildPlayer {
private lastPosition = 0;
private lastProgressAt = 0;
private loadFailCount = 0;
/** Soloist 스트림이 곡 도중에 끊긴 재생 위치(ms). end 이벤트에서 유튜브 음원으로 그 위치부터 이어 재생한다. */
private soloistCutAt: number | undefined;
constructor(
public guild: Guild,
@@ -73,21 +66,6 @@ export class GuildPlayer {
try {
if (this.isDead) return;
if (data.reason === "replaced") return;
// Soloist 스트림이 곡 도중에 끊기면(다른 기기에서 같은 계정으로 재생 등) Lavalink는 exception 뒤
// finished(또는 loadFailed)로 끝낸다 → 다음 곡으로 넘기지 않고 같은 곡의 유튜브 음원으로 그 위치부터 이어 재생
const cutAt = this.soloistCutAt;
this.soloistCutAt = undefined;
const cut = this.queue[0];
if (cutAt !== undefined && cut?._soloist && data.reason !== "stopped" && data.reason !== "cleanup") {
cut._soloist = false;
const encoded = await this.getResolved(cut);
if (this.isDead || this.queue[0] !== cut) return;
if (encoded) {
Logger.warn(`[GuildPlayer] Soloist 재생이 ${cutAt}ms에서 끊겨 유튜브로 이어서 재생합니다: "${cut.info.title}"`);
await this.player.playTrack({ track: { encoded }, position: cutAt });
return;
}
}
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
@@ -99,13 +77,9 @@ export class GuildPlayer {
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
if (this.isDead) return;
if (this.queue[0]?.encoded === cur.encoded) {
// Soloist 스트림이 끊긴 경우도 여기로 온다 → 유튜브 음원으로 다시 재생
cur._soloist = false;
const reEncoded = await this.resolvePlayable(cur);
if (reEncoded) {
await this.player.playTrack({ track: { encoded: reEncoded } });
return;
}
await this.player.playTrack({ track: { encoded: reEncoded } });
return;
}
} else {
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
@@ -157,7 +131,6 @@ export class GuildPlayer {
this.player.on("exception", async (data) => {
try {
if (this.queue[0]?._soloist) this.soloistCutAt = this.player.position;
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
@@ -220,10 +193,9 @@ export class GuildPlayer {
this.attachPlayerListeners();
await this.player.setGlobalVolume(savedVolume || 50);
// 곡이 있었으면 이어서 재생 (Soloist 스트림은 이어 받을 수 없으므로 유튜브 음원으로 그 위치부터)
if (currentTrack) currentTrack._soloist = false;
const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
if (currentTrack && reEncoded) {
// 곡이 있었으면 이어서 재생
if (currentTrack) {
const reEncoded = await this.resolvePlayable(currentTrack);
await this.player.playTrack({
track: { encoded: reEncoded },
position: currentPosition,
@@ -271,11 +243,9 @@ export class GuildPlayer {
}
}
/** 여러 곡을 대기열에 넣는다. 넣은 대기열 항목을 돌려준다. */
public async addTracks(tracks: Track[], userId: string): Promise<QueueTrack[]> {
public async addTracks(tracks: Track[], userId: string) {
const first = this.queue.length === 0;
const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
for (const track of tracks) this.queue.push({ ...track, userId });
if (first) {
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
this.prefetch(this.queue[1]);
@@ -285,67 +255,31 @@ export class GuildPlayer {
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
return added;
}
/**
* 재생목록의 나머지 곡을 대기열 뒤에 붙인다(백그라운드로 이어 받는 용도). 디스코드 메시지는 고치지 않고
* 대시보드에만 알린다(페이지마다 메시지를 고치면 디스코드 요청 제한에 걸린다).
* group이 있으면('-s'로 넣은 재생목록) 그 곡들이 있던 자리끼리 다시 섞는다. 지금 곡과 미리 변환 중인 다음 곡은 그대로 둔다.
*/
public appendTracks(tracks: Track[], userId: string, group?: Set<object>) {
const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
if (group) {
for (const item of added) group.add(item);
const slots: number[] = [];
for (let i = 2; i < this.queue.length; i++) if (group.has(this.queue[i])) slots.push(i);
const mixed = shuffle(slots.map((i) => this.queue[i]));
slots.forEach((slot, k) => { this.queue[slot] = mixed[k]; });
}
this.prefetch(this.queue[1]);
Redis?.publishState("queue_update", { guildId: this.guild.id });
}
/** 유튜브(및 스포티파이) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백.
* 스포티파이 곡은 여기서(재생 직전에) 유튜브 원곡을 찾아 그 오디오를 쓴다. 재생목록 항목은 대기열에
* 곡 정보만 들어 있어서, 원곡을 못 찾으면 재생할 수 없음을 뜻하는 빈 문자열을 돌려준다. */
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
private async resolvePlayable(track: QueueTrack): Promise<string> {
let source: Track = track;
let fallback = Spotify.isLazyTrack(track) ? "" : track.encoded;
try {
if (track.info.sourceName === "spotify") {
const matched = await lavalinkManager.matchSpotifyTrack(track);
if (matched) {
source = matched;
fallback = matched.encoded;
// 재생목록 커버를 임시로 쓰던 곡은 찾은 곡의 이미지로 바꾼다(앨범은 앨범 커버가 맞으므로 유지)
if (!track.info.artworkUrl || (track.pluginInfo as any)?.coverIsPlaylist) {
track.info.artworkUrl = matched.info.artworkUrl ?? track.info.artworkUrl;
}
} else if (!fallback) {
Logger.warn(`[GuildPlayer] 유튜브에서 같은 곡을 찾지 못해 건너뜁니다: "${track.info.author} - ${track.info.title}"`);
return "";
}
}
const info: any = (source as any).info ?? {};
// 실험: ytresolver 비활성화 시 Lavalink youtube-source(poToken)로 직접 재생 — yt-dlp 단계 생략.
if (!Config.ytresolverEnabled) return track.encoded;
const info: any = (track as any).info ?? {};
const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return fallback;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return fallback;
if (!target) return track.encoded;
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
if (!r.ok) return fallback;
if (!r.ok) return track.encoded;
const data = (await r.json()) as { url?: string };
if (!data.url) return fallback;
if (!data.url) return track.encoded;
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return fallback;
if (!node) return track.encoded;
const res = await node.rest.resolve(data.url);
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
return fallback;
return track.encoded;
} catch (e) {
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
return fallback;
return track.encoded;
}
}
@@ -355,7 +289,7 @@ export class GuildPlayer {
track._resolved = this.resolvePlayable(track).catch((e) => {
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
return Spotify.isLazyTrack(track) ? "" : track.encoded;
return track.encoded;
});
}
@@ -365,46 +299,12 @@ export class GuildPlayer {
return track._resolved!;
}
/**
* 스포티파이 곡을 Soloist(스포티파이 공식 헤드리스 클라이언트)로 재생할 encoded를 만든다.
* 설정이 없거나, 페어링 전이거나, 다른 길드가 쓰는 중이거나, 재생이 안 되면 "" → 호출한 쪽이 유튜브로 재생한다.
* 계정 하나로 한 번에 한 곡만 재생되므로 다음 곡 미리 준비(prefetch)는 하지 않고 재생 직전에만 부른다.
*/
private async resolveSoloist(track: QueueTrack): Promise<string> {
if (!Soloist.enabled || track.info.sourceName !== "spotify") return "";
const url = await Soloist.prepare(track.info.identifier, `${Config.appId}:${this.guild.id}`, track.info.length);
if (!url) return "";
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return "";
try {
const res = await node.rest.resolve(url);
if (res?.loadType === LoadType.TRACK) return res.data.encoded;
Logger.warn(`[GuildPlayer] Soloist 스트림을 Lavalink가 읽지 못함(${res?.loadType}), 유튜브로 재생합니다.`);
} catch (e) {
Logger.warn(`[GuildPlayer] Soloist 스트림 확인 실패, 유튜브로 재생합니다: ${String(e)}`);
}
return "";
}
public async playNext(): Promise<void> {
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
const tResolve = Date.now();
// 스포티파이 곡은 Soloist를 먼저 시도하고, 안 되면 유튜브 음원(미리 준비해 둔 것)으로 재생한다.
this.soloistCutAt = undefined;
let encoded = await this.resolveSoloist(track);
if (this.isDead || this.queue[0] !== track) return; // 준비하는 동안 건너뛰기/정지됨
track._soloist = !!encoded;
if (encoded) this.prefetch(track); // 도중에 끊기거나 위치를 옮길 때 쓸 유튜브 음원도 준비
else encoded = await this.getResolved(track);
const encoded = await this.getResolved(track);
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
if (!encoded) {
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
if (this.isDead || this.queue[0] !== track) return;
this.queue.shift();
if (this.queue.length === 0 && this.isRecommend && this.canRecommend && this.lastPlayedTrack) return this.autoPlay();
return this.playNext();
}
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } });
@@ -423,7 +323,6 @@ export class GuildPlayer {
}
public stop() {
this.queueEpoch++;
this.queue = [];
this.canRecommend = false;
this.player.stopTrack();
@@ -458,18 +357,6 @@ export class GuildPlayer {
public seek(num: number) {
if (!this.isPlaying) return;
if (!this.nowTrack) return;
const track = this.nowTrack;
if (track._soloist) {
// Soloist 스트림은 위치 이동이 안 되므로 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
const paused = this.isPaused;
this.getResolved(track).then(async (encoded) => {
if (!encoded || this.isDead || this.nowTrack !== track) return;
track._soloist = false;
await this.player.playTrack({ track: { encoded }, position: num, paused });
Redis?.publishState("player_update", { guildId: this.guild.id });
}).catch((e) => Logger.warn(`[GuildPlayer] 위치 이동(유튜브 전환) 실패: ${String(e)}`));
return;
}
this.player.seekTo(num);
Redis?.publishState("player_update", { guildId: this.guild.id });
}
@@ -647,7 +534,6 @@ export class GuildPlayer {
this.delete(true);
}, idleEndTime);
this.lastPlayedTrack = undefined;
this.queueEpoch++;
this.queue = [];
this.canRecommend = true;
this.setMsg();

View File

@@ -7,27 +7,12 @@ import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle";
import { Spotify, SpotifyCollectionMax, type SpotifyCollectionPage } from "../utils/api/Spotify";
import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
/** 스포티파이 재생목록에서 아직 받지 않은 나머지(offset부터 total까지). 대기열 추가 후 백그라운드로 이어 받는다. */
type SpotifyRemaining = { kind: "playlist" | "album"; id: string; offset: number; total: number; cover?: string };
/** 해석 결과. */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; spotifyRemaining?: SpotifyRemaining };
/** 스포티파이 재생목록 첫 페이지 곡 수(이만큼 받으면 바로 재생 시작) / 이후 백그라운드 페이지 크기·동시 요청 수. */
const SpotifyFirstPage = 100;
const SpotifyPageSize = 1000;
const SpotifyPageConcurrency = 3;
/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */
const MatchCacheSize = 500;
export class LavalinkManager {
public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map();
private matchCache: Map<string, Promise<Track | null>> = new Map();
constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
@@ -58,6 +43,11 @@ export class LavalinkManager {
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
*/
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 child = spawn(process.execPath, [script], {
env: {
@@ -124,40 +114,17 @@ export class LavalinkManager {
* 오디오 URL이 이미 준비되도록 한다.
* 못 찾으면 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);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim());
// 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
// 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 첫 100곡(~0.5초)만 받고 바로
// 재생을 시작하며, 나머지(최대 SpotifyCollectionMax곡)는 addResolved 뒤에 백그라운드로 이어 받는다.
// partner API가 실패하면 임베드 페이지(100곡), 그것도 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
const collection = isUrl ? Spotify.parseCollection(text) : null;
if (collection) {
let page: SpotifyCollectionPage | null = await Spotify.getCollectionPage(collection.kind, collection.id, 0, SpotifyFirstPage);
if (!page || page.tracks.length === 0) {
const embed = await Spotify.getCollectionTracks(collection.kind, collection.id);
page = { tracks: embed.tracks, total: 0, cover: embed.cover }; // total 0 → 이어 받지 않음
}
if (page.tracks.length > 0) {
const isPlaylist = collection.kind === "playlist";
const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, page.cover, isPlaylist));
if (flags.has('s')) shuffle(tracks);
this.prewarmTrack(tracks[0]);
const total = Math.min(page.total, SpotifyCollectionMax);
const spotifyRemaining = total > SpotifyFirstPage
? { kind: collection.kind, id: collection.id, offset: SpotifyFirstPage, total, cover: page.cover }
: undefined;
return { tracks, playlist: true, flags, spotifyRemaining };
}
}
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 끝까지 기다린 뒤 다시
// ytsearch(~0.5초)를 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도
// 추가 대기가 없다. (적중 시엔 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 기다린 뒤 다시 ytsearch(~0.5초)를
// 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도 추가 대기가 없다.
// (적중 시에는 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
let searchText: string;
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
if (isUrl) searchText = text;
@@ -171,19 +138,14 @@ export class LavalinkManager {
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
}
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트+길이)로
// 유튜브 검색 결과 중 같은 곡의 원곡 오디오를 골라 대체한다.
// (유튜브뮤직 검색은 현재 노래 대신 팟캐스트·분석 영상을 주는 경우가 많아 엉뚱한 곡이 걸렸다)
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
const spotifyTrackId = Spotify.parseTrackId(searchText);
if (spotifyTrackId) {
const meta = await Spotify.getTrackById(spotifyTrackId);
if (meta) {
const matched = await this.findYoutubeMatch(meta);
if (matched) {
this.prewarmTrack(matched);
return { tracks: [matched], playlist: false, flags };
}
searchText = `ytsearch:${meta.title} ${meta.artist}`.trim();
const q = `${meta.title} ${meta.artist}`.trim();
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
}
}
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
@@ -198,7 +160,6 @@ export class LavalinkManager {
if (result.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
playlist = true;
if (flags.has('s')) shuffle(tracks);
} else if (result.loadType === LoadType.TRACK) {
tracks = [result.data];
} else if (result.loadType === LoadType.SEARCH) {
@@ -215,106 +176,26 @@ export class LavalinkManager {
*/
public async addResolved(
player: GuildPlayer,
resolved: Resolved,
resolved: { tracks: Track[]; playlist: boolean; flags: Set<string> },
userId: string,
) {
if (resolved.playlist) {
// '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
const added = await player.addTracks(resolved.tracks, userId);
if (resolved.spotifyRemaining) {
const group = resolved.flags.has("s") ? new Set(added) : undefined;
this.appendSpotifyPages(player, resolved.spotifyRemaining, userId, group)
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 불러오기 실패: ${String(e)}`));
}
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
return;
}
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;
}
}
/**
* 스포티파이 재생목록의 나머지 곡을 페이지 단위로 받아 대기열 뒤에 순서대로 붙인다(곡 정보만, 변환은 재생 직전).
* 정지·모두 지우기·퇴장으로 대기열이 바뀌면(queueEpoch 변경) 중단한다.
* group: '-s'로 넣은 재생목록이면 그 곡들 — 새 페이지가 올 때마다 그 곡들끼리 다시 섞는다.
*/
private async appendSpotifyPages(player: GuildPlayer, rest: SpotifyRemaining, userId: string, group?: Set<object>) {
const epoch = player.queueEpoch;
const stopped = () => this.getPlayer(player.guild.id) !== player || player.queueEpoch !== epoch;
const isPlaylist = rest.kind === "playlist";
let added = 0;
let offset = rest.offset;
while (offset < rest.total) {
const offsets: number[] = [];
for (let k = 0; k < SpotifyPageConcurrency && offset < rest.total; k++, offset += SpotifyPageSize) offsets.push(offset);
const pages = await Promise.all(offsets.map((o) =>
Spotify.getCollectionPage(rest.kind, rest.id, o, Math.min(SpotifyPageSize, rest.total - o))));
if (stopped()) {
Logger.info(`[LavalinkManager] 스포티파이 재생목록 불러오기 중단 (정지/지우기/퇴장), ${added}곡 추가됨`);
return;
}
pages.forEach((page, k) => {
if (!page) {
Logger.warn(`[LavalinkManager] 스포티파이 재생목록 ${offsets[k]}번째부터 한 페이지를 받지 못해 건너뜁니다.`);
return;
}
const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, rest.cover, isPlaylist));
if (tracks.length === 0) return;
player.appendTracks(tracks, userId, group);
added += tracks.length;
});
}
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}곡 추가 완료 (전체 ${rest.total}곡 중 ${rest.offset}곡은 먼저 추가)`);
player.setMsg({ player: false, queue: true });
}
/**
* 스포티파이 곡(재생목록 항목·자동재생 추천곡)을 재생할 유튜브 원곡 트랙으로 바꾼다. 못 찾으면 null.
* 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다.
*/
public matchSpotifyTrack(track: Track): Promise<Track | null> {
const key = track.info.identifier;
const cached = this.matchCache.get(key);
if (cached) return cached;
const pending = this.findYoutubeMatch({ title: track.info.title, artist: track.info.author, durationMs: track.info.length });
this.matchCache.set(key, pending);
// 못 찾은 결과는 남기지 않는다(다음에 다시 시도)
pending.then((t) => { if (!t && this.matchCache.get(key) === pending) this.matchCache.delete(key); }, () => this.matchCache.delete(key));
if (this.matchCache.size > MatchCacheSize) this.matchCache.delete(this.matchCache.keys().next().value!);
return pending;
}
/**
* 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget).
* GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
*/
public prewarmTrack(track: Track) {
try {
if (!Config.ytresolverEnabled) return; // yt-dlp 미사용 실험 모드에서는 예열 자체가 불필요
const info: any = (track as any).info ?? {};
const src = info.sourceName;
// 스포티파이 곡은 유튜브 원곡 매칭부터 예열하고, 찾으면 그 유튜브 주소를 리졸브해 둔다.
if (src === "spotify") {
this.matchSpotifyTrack(track).then((m) => { if (m) this.prewarmTrack(m); }).catch(() => {});
return;
}
if (src !== "youtube" && src !== "youtubemusic") return;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});

View File

@@ -9,14 +9,6 @@ import { channelJoin } from "../commands/join";
import { GuildPlayer } from "./GuildPlayer";
import { Guild, VoiceChannel } from "discord.js";
import { SongItem } from "../types/Track";
import { DB } from "../utils/Database";
import { buttonRecommend } from "../utils/music/Button";
/**
* 대시보드에 보내는 대기열 앞부분 곡 수. 스포티파이 재생목록은 최대 5만 곡이라 전부 보내면 응답이 수십 MB가 되고
* 브라우저가 멈춘다. 곡 수·총 길이는 따로 보내고, 순서 변경(queue_set)은 이 앞부분만 바꾸고 뒤는 그대로 둔다.
*/
const QueueListLimit = 200;
type SubAction =
"search" |
@@ -29,9 +21,7 @@ type SubAction =
"player_volume" |
"queue_list" |
"queue_set" |
"queue_remove" |
"queue_shuffle" |
"player_recommend";
"queue_remove";
export const RedisClient = () => {
if (Config.redis.state) return new RedisClientClass();
@@ -151,10 +141,7 @@ class RedisClientClass {
isPaused: player?.isPaused,
position: player?.position,
volume: player?.volume,
track: player?.nowTrack ?? null,
// 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름
recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false),
voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null,
track: player?.nowTrack ?? null
}));
}
if (data.action === "queue_list") {
@@ -165,15 +152,7 @@ class RedisClientClass {
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
const upcoming = player?.queue ?? [];
let totalMs = 0;
for (let i = 1; i < upcoming.length; i++) totalMs += Number(upcoming[i].info?.length) || 0;
await this.reply(resultKey, 60, JSON.stringify({
success: true,
queue: upcoming.slice(1, 1 + QueueListLimit),
total: Math.max(upcoming.length - 1, 0),
totalMs,
}));
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
}
if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`;
@@ -183,20 +162,11 @@ class RedisClientClass {
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
// 대시보드는 대기열 앞부분(windowSize곡, 기본 QueueListLimit)만 갖고 있으므로 그 부분만 바꾸고 뒤는 남긴다.
// clearAll(모두 지우기)이면 뒤까지 비우고, 백그라운드로 이어 받던 재생목록도 멈춘다(queueEpoch).
const windowSize = typeof data.windowSize === "number" && data.windowSize >= 0 ? data.windowSize : QueueListLimit;
const tail = data.clearAll === true ? [] : context.player.queue.slice(1 + windowSize);
if (data.clearAll === true) context.player.queueEpoch++;
const head: typeof context.player.queue = [];
if (nowTrack) head.push(nowTrack);
context.player.queue.length = 0;
if (nowTrack) context.player.queue.push(nowTrack);
for (const rawTrack of data.newQueue) {
if (!rawTrack?.encoded) continue;
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
const { _resolved, ...track } = rawTrack;
head.push(track);
if (rawTrack.encoded) context.player.queue.push(rawTrack);
}
context.player.queue = head.concat(tail);
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg();
}
@@ -224,39 +194,6 @@ class RedisClientClass {
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
}
if (data.action === "queue_shuffle") {
const resultKey = `queue:shuffle:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.queue.length < 3) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "섞을 곡이 2곡 이상 있어야 합니다." }));
context.player.setShuffle();
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_recommend") {
// 자동재생 토글. 재생 중이 아니어도 바꿀 수 있어야 하므로 음성채널 접속(getContext)을 거치지 않는다.
const resultKey = `player:recommend:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const recGuild = await getGuildById(data.serverId);
if (!recGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(recGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(recGuild.id);
if (player) {
// 재생 중이면 봇과 같은 음성채널에 있는 사람만 바꿀 수 있다(다른 조작과 같은 규칙).
const voiceChannel = await getVoiceChannelById(recGuild, data.userId);
if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
}
player.setRecommend();
} else {
await buttonRecommend(recGuild);
}
const recommend = DB.guild.get(recGuild.id)?.options.recommend ?? false;
await this.reply(resultKey, 60, JSON.stringify({ success: true, recommend }));
this.publishState("player_update", { guildId: recGuild.id });
}
if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));

View File

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

View File

@@ -106,9 +106,10 @@ export const Config = {
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
// 스포티파이 Soloist 중계 서버(soloist-bridge) 주소. 비우면 스포티파이 곡도 유튜브로만 재생한다.
// Lavalink(.6)도 이 주소로 스트림을 받으므로 LAN 주소여야 한다. 예: http://192.168.10.5:8780
soloistUrl: (process.env.SOLOIST_URL?.trim() || "").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() || "",

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

@@ -1,53 +0,0 @@
import { Config } from "../Config";
import { Logger } from "../Logger";
/** soloist-bridge가 첫 소리를 확인할 때까지 기다리는 최대 시간(브리지 쪽 15초 + 여유). */
const PrepareTimeoutMs = 20_000;
/** 실패하는 데 이보다 오래 걸렸으면(소리가 안 나와 시간 초과 등) 잠시 Soloist를 쉬어, 곡마다 오래 기다리지 않게 한다. */
const SlowFailMs = 5_000;
const CooldownMs = 5 * 60_000;
let cooldownUntil = 0;
/**
* 스포티파이 공식 헤드리스 클라이언트(Soloist) 중계 서버(soloist-bridge, 리포 루트 soloist/) 클라이언트.
* 스포티파이 곡을 Soloist로 재생해 받은 MP3 스트림 주소를 돌려준다. 실패하면 null → 호출한 쪽이 유튜브로 재생한다.
*/
export const Soloist = {
get enabled(): boolean {
return !!Config.soloistUrl;
},
/**
* 곡 재생을 준비한다(브리지가 Soloist를 띄우고 실제 소리가 나올 때까지 기다린다).
* 성공하면 Lavalink에 넘길 스트림 주소, 실패(미설정·미페어링·다른 길드가 사용 중·재생 불가)하면 null.
* owner: 같은 owner의 새 요청은 이전 곡을 끊고 시작하고, 다른 owner가 쓰는 중이면 거절된다.
*/
async prepare(trackId: string, owner: string, durationMs: number): Promise<string | null> {
if (!this.enabled || Date.now() < cooldownUntil) return null;
const t0 = Date.now();
const url = `${Config.soloistUrl}/prepare?track=${encodeURIComponent(trackId)}`
+ `&owner=${encodeURIComponent(owner)}&duration=${Math.max(0, Math.floor(durationMs || 0))}`;
try {
const r = await fetch(url, { signal: AbortSignal.timeout(PrepareTimeoutMs) });
const data = await r.json().catch(() => ({})) as { path?: string; error?: string; readyMs?: number };
if (!r.ok || !data.path) {
Logger.warn(`[Soloist] 재생 준비 실패(${r.status} ${data.error ?? ""}), 유튜브로 재생합니다: ${trackId}`);
// busy(다른 길드가 사용 중)·preempted(이 길드가 곧바로 다음 곡을 요청함)는 정상 상황이라 쉬지 않는다
if (data.error !== "busy" && data.error !== "preempted") this.coolDownIfSlow(t0);
return null;
}
Logger.log(`[Soloist] 재생 준비 완료 ${data.readyMs ?? "?"}ms: ${trackId}`);
return `${Config.soloistUrl}${data.path}`;
} catch (e) {
Logger.warn(`[Soloist] 브리지 요청 실패, 유튜브로 재생합니다: ${String(e)}`);
this.coolDownIfSlow(t0);
return null;
}
},
coolDownIfSlow(t0: number) {
if (Date.now() - t0 < SlowFailMs) return;
cooldownUntil = Date.now() + CooldownMs;
Logger.warn(`[Soloist] 실패에 시간이 오래 걸려 ${CooldownMs / 60_000}분 동안 유튜브로만 재생합니다.`);
},
};

View File

@@ -2,39 +2,6 @@
import "dotenv/config";
import { Logger } from "../Logger";
import { SongItem } from "../../types/Track";
import type { Track } from "shoukaku";
import type { SongMeta } from "../music/TrackMatch";
/** 스포티파이 재생목록/앨범의 곡 하나. artworkUrl: 곡의 앨범 커버(임베드 페이지에서 받으면 없음). */
export type SpotifyEntry = SongMeta & { id: string; artworkUrl?: string };
/** 재생목록/앨범 한 페이지. total: 재생목록 전체 항목 수(다음 페이지를 받을지 판단). */
export type SpotifyCollectionPage = { tracks: SpotifyEntry[]; total: number; cover?: string };
/** 재생목록/앨범에서 불러올 최대 곡 수. */
export const SpotifyCollectionMax = 50000;
const LAZY_PREFIX = "spotify-lazy:";
// 스포티파이 웹 플레이어가 쓰는 GraphQL(partner API). 익명 웹 토큰(토크너)으로 재생목록을 한 번에 최대 수천 곡씩
// 받을 수 있다(1000곡 ~1초). persisted query 해시는 Lavalink LavaSrc 4.8.3과 같은 값이며, 스포티파이가 바꾸면
// "PersistedQueryNotFound" 오류가 나므로 그때 갱신해야 한다(실패 시 임베드 페이지 100곡으로 폴백).
const PARTNER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query";
const PARTNER_QUERY = {
playlist: { operationName: "fetchPlaylist", sha256Hash: "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77" },
album: { operationName: "getAlbum", sha256Hash: "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10" },
} as const;
/** 크기별 이미지 목록에서 300px 정도(대기열 썸네일·재생 화면에 충분)를, 없으면 가장 큰 것을 고른다. */
const pickImage = (sources: any): string | undefined => {
if (!Array.isArray(sources) || sources.length === 0) return undefined;
const mid = sources.find((x: any) => x?.width === 300);
const best = mid ?? sources.reduce((a: any, b: any) => ((b?.width ?? 0) > (a?.width ?? 0) ? b : a));
return typeof best?.url === "string" ? best.url : undefined;
};
const artistNames = (artists: any): string =>
(Array.isArray(artists?.items) ? artists.items : []).map((a: any) => a?.profile?.name).filter(Boolean).join(", ");
const trackIdOf = (uri: unknown): string | undefined => String(uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1];
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
@@ -58,20 +25,15 @@ export const Spotify = {
cached: "",
expiresAt: 0,
},
// 토크너(웹 플레이어) 토큰은 정식 API 토큰(_token)과 쓰임새가 달라 캐시를 따로 둔다.
_webToken: {
cached: "",
expiresAt: 0,
},
async getTokenByTokener(): Promise<string | null> {
if (this._webToken.cached && Date.now() < this._webToken.expiresAt - 60000) return this._webToken.cached;
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached;
try {
const response = await fetch(TOKENR_URL, { signal: AbortSignal.timeout(10000) });
const response = await fetch(TOKENR_URL);
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
const jsonData = await response.json() as TOKENER_RESPONSE;
this._webToken.cached = jsonData.accessToken;
this._webToken.expiresAt = jsonData.accessTokenExpirationTimestampMs;
this._token.cached = jsonData.accessToken;
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs;
return jsonData.accessToken;
} catch (err) {
Logger.error(`토크너 접근 실패: ${err}`);
@@ -166,173 +128,14 @@ export const Spotify = {
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 {
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;
},
/** 스포티파이 플레이리스트/앨범 링크면 종류와 id를 추출. 아니면 null. */
parseCollection(text: string): { kind: "playlist" | "album"; id: 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;
},
/**
* 플레이리스트/앨범의 곡 목록 한 페이지(offset부터 limit개)를 partner API로 가져온다.
* 곡마다 id·제목·아티스트·길이·앨범 커버가 들어 있다. 로컬 파일·삭제된 곡은 빠진다. 실패하면 null.
*/
async getCollectionPage(kind: "playlist" | "album", id: string, offset: number, limit: number): Promise<SpotifyCollectionPage | null> {
const query = PARTNER_QUERY[kind];
const variables = kind === "playlist"
? { uri: `spotify:playlist:${id}`, offset, limit, enableWatchFeedEntrypoint: false }
: { uri: `spotify:album:${id}`, locale: "", offset, limit };
for (let attempt = 0; attempt < 2; attempt++) {
const token = await this.getTokenByTokener();
if (!token) return null;
try {
const response = await fetch(PARTNER_API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "App-Platform": "WebPlayer" },
body: JSON.stringify({ operationName: query.operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash: query.sha256Hash } } }),
signal: AbortSignal.timeout(30000),
});
if (response.status === 401 && attempt === 0) {
this._webToken.cached = ""; // 토큰 만료 → 새로 받아 한 번 더
continue;
}
if (!response.ok) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 에러 (${response.status}): ${(await response.text()).slice(0, 200)}`);
return null;
}
const json: any = await response.json();
if (kind === "playlist") {
const pl = json?.data?.playlistV2;
if (pl?.__typename !== "Playlist") {
Logger.error(`스포티파이 재생목록을 찾지 못했습니다: ${id} (${pl?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const items: any[] = Array.isArray(pl.content?.items) ? pl.content.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.itemV2?.data;
const trackId = t?.__typename === "Track" ? trackIdOf(t.uri) : undefined;
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.trackDuration?.totalMilliseconds) || 0,
artworkUrl: pickImage(t.albumOfTrack?.coverArt?.sources),
});
}
return { tracks, total: Number(pl.content?.totalCount) || 0, cover: pickImage(pl.images?.items?.[0]?.sources) };
}
const album = json?.data?.albumUnion;
if (album?.__typename !== "Album") {
Logger.error(`스포티파이 앨범을 찾지 못했습니다: ${id} (${album?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const cover = pickImage(album.coverArt?.sources);
const items: any[] = Array.isArray(album.tracksV2?.items) ? album.tracksV2.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.track;
const trackId = trackIdOf(t?.uri);
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.duration?.totalMilliseconds) || 0,
artworkUrl: cover,
});
}
return { tracks, total: Number(album.tracksV2?.totalCount) || 0, cover };
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 조회 실패: ${err}`);
return null;
}
}
return null;
},
/**
* 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 공개 임베드 페이지에서 가져온다.
* partner API(getCollectionPage)가 실패할 때 쓰는 폴백이며, 최대 100곡까지만 제공된다. 실패하면 tracks가 빈 배열.
* (2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이다)
*/
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<{ tracks: SpotifyEntry[]; cover?: string }> {
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 { tracks: [] };
}
const html = await response.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
const entity = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity : null;
const list = entity?.trackList;
if (!Array.isArray(list)) {
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
return { tracks: [] };
}
const tracks = list
.map((t: any) => ({ t, id: String(t?.uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1] }))
.filter(({ t, id }: any) => id && t?.title)
.map(({ t, id }: any) => ({
id,
title: String(t.title),
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
durationMs: Number(t.duration) || 0,
}));
// 앨범은 visualIdentity.image(크기별 목록), 플레이리스트는 coverArt.sources 에 커버가 있다
const images: any[] = Array.isArray(entity?.visualIdentity?.image) ? entity.visualIdentity.image : [];
const largest = images.reduce((best, img) => (!best || (img?.maxWidth ?? 0) > (best?.maxWidth ?? 0) ? img : best), null);
const cover = largest?.url ?? entity?.coverArt?.sources?.[0]?.url;
return { tracks, cover: typeof cover === "string" ? cover : undefined };
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
return { tracks: [] };
}
},
/**
* 재생목록 곡을 '아직 유튜브로 변환하지 않은' 대기열 항목으로 만든다.
* encoded는 Lavalink 트랙이 아니라 표식(`spotify-lazy:<id>`)이고, 재생 직전에
* GuildPlayer.resolvePlayable 이 유튜브 원곡을 찾아 실제 재생용 트랙으로 바꾼다.
* 곡 자체의 앨범 커버(entry.artworkUrl)가 없으면 cover(재생목록/앨범 커버)를 쓴다.
* coverIsPlaylist: cover가 곡 앨범아트가 아니라 재생목록 커버라서, 변환할 때 곡 이미지로 바꿔도 되는지.
*/
toLazyTrack(entry: SpotifyEntry, cover: string | undefined, coverIsPlaylist: boolean): Track {
return {
encoded: `${LAZY_PREFIX}${entry.id}`,
info: {
identifier: entry.id,
isSeekable: true,
author: entry.artist,
length: entry.durationMs,
isStream: false,
position: 0,
title: entry.title,
uri: `https://open.spotify.com/track/${entry.id}`,
artworkUrl: entry.artworkUrl ?? cover,
sourceName: "spotify",
},
pluginInfo: { lazy: true, coverIsPlaylist: !entry.artworkUrl && coverIsPlaylist },
};
},
/** toLazyTrack 으로 만든(아직 변환 전인) 항목인지. 이런 항목의 encoded는 Lavalink에 넘기면 안 된다. */
isLazyTrack(track: Track): boolean {
return typeof track.encoded === "string" && track.encoded.startsWith(LAZY_PREFIX);
},
/** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
async getTrackById(id: string): Promise<SongMeta | null> {
/** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */
async getTrackById(id: string): Promise<{ title: string; artist: string } | null> {
try {
const token = await this.getToken();
if (!token) return null;
@@ -348,7 +151,6 @@ export const Spotify = {
return {
title: track.name,
artist: (track.artists ?? []).map((a) => a.name).join(", "),
durationMs: track.duration_ms ?? 0,
};
} catch (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";
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
const searchCache = new Map<string, string>();
// 검색 결과 행의 종류 표기(부제 첫 칸). 노래/동영상이 아닌 팟캐스트 에피소드·재생목록 등은 곡 후보에서 뺀다.
// (현재 YTM 검색이 노래 대신 '분석/루프' 같은 팟캐스트 에피소드를 상단에 주는 경우가 많다)
const NON_SONG_TYPES = new Set(["에피소드", "팟캐스트", "재생목록", "아티스트", "앨범", "싱글", "EP", "프로필", "Episode", "Podcast", "Playlist", "Artist", "Album", "Single", "Profile"]);
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
const defaultCookies: Cookies[] = [
@@ -134,7 +131,6 @@ export const YoutubeMusic = {
// 1. 최상단 'Top Result' 카드 파싱
if (section.musicCardShelfRenderer) {
const card = section.musicCardShelfRenderer;
const cardType = card.subtitle?.runs?.[0]?.text;
const title = card.title?.runs?.[0]?.text;
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 thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
if (videoId && title && !NON_SONG_TYPES.has(cardType)) {
if (videoId && title) {
results.push({
videoId,
url: `https://music.youtube.com/watch?v=${videoId}`,
@@ -177,7 +173,6 @@ export const YoutubeMusic = {
let artist = "Unknown Artist";
const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
if (NON_SONG_TYPES.has(subtitleRuns[0]?.text)) continue;
if (subtitleRuns.length >= 3) {
artist = subtitleRuns[2]?.text || artist;

View File

@@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
});
}
export const buttonRecommend = async (guild: Guild) => {
const buttonRecommend = async (guild: Guild) => {
const gdb = DB.guild.get(guild.id);
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
if (!change) return;

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

@@ -50,7 +50,7 @@ site: 그 키를 짧게 폴링(100ms→최대400ms 백오프, 기본 3초 타임
```
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, `queue_shuffle`, `player_recommend`.
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
```
@@ -66,9 +66,8 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `autoplay`(자동재생 토글), `events`(SSE)
- **queue**: `list`, `set`, `remove`, `shuffle`(다음 트랙 섞기), `events`(SSE)
- `play`/`playlist`는 음성채널 접속 + 곡·재생목록 해석까지 기다려야 해서 응답 대기 시간을 15초로 둔다.
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE)
- **queue**: `list`, `set`, `remove`, `events`(SSE)
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
- **auth**: `[...nextauth]`(Discord OAuth)
@@ -79,67 +78,11 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
## 5. UI 구성 (`src/`)
디자인은 **유튜브 뮤직(웹) 다크 테마**를 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
### 5-1. 페이지(주소)
| 주소 | 내용 | 접근 규칙 |
|------|------|-----------|
| `/` | 바로 이동만 한다 | 로그인 안 했으면 `/login`, 했으면 `/servers` |
| `/login` | 로그인 전용 페이지(Discord 로그인 버튼 + 사용 3단계 안내, 로그인 오류 표시) | 이미 로그인했으면 `callbackUrl`(없으면 `/servers`)로 이동 |
| `/servers` | 서버 선택 전용 페이지(봇이 있는 내 서버, 최근 사용 서버를 맨 앞에 "최근" 표시, 새로고침, 8개 넘으면 이름 검색) | 로그인 필수 |
| `/servers/[serverId]` | 유튜브 뮤직 형태의 봇 플레이어. 검색하면 `?q=검색어`가 붙어 뒤로가기로 홈↔검색을 오간다 | 로그인 필수. 내 서버 목록에 없는 서버면 "열 수 없어요" 안내 후 서버 선택으로 |
- 로그인 검사는 서버 컴포넌트에서 한다(`lib/auth.ts`의 `requirePageSession`). 돌아올 주소(`callbackUrl`)는 같은 사이트 경로만 허용한다.
- NextAuth 로그인/오류 화면도 `/login`으로 돌린다(`authOptions.pages`).
- 쓰다가 세션이 끊기면 클라이언트에서 `/login`으로 보낸다.
### 5-2. 파일 구성
- `app/login`, `app/servers`, `app/servers/[serverId]` — 위 페이지(서버 컴포넌트, 로그인 검사 후 화면 컴포넌트 렌더).
- `components/pages/` — `LoginView`, `ServerPicker`, `PlayerApp`(플레이어 전체 배치·검색 주소 관리·탭 제목).
- `components/layout/` — `TopNav`(☰·로고·검색·최근 검색어), `LeftSidebar`(홈/검색/서버 변경 + 조작 중인 서버·음성채널),
`AccountMenu`(프로필 → 서버 변경·로그아웃만).
- `components/player/` — `MainContent`(서버 홈·검색 결과·주소 불러오기), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면),
`QueueList`(다음 트랙 + 섞기/모두 지우기/자동재생).
- `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. `ToastProvider.tsx` — 하단 스낵바.
- `hooks/` — `usePlayer`(재생 상태·조작·단축키·자동재생), `useQueue`(대기열·섞기·지우기), `useServers`(서버 목록·최근 서버).
재생 상태·대기열은 `PlayerApp`에서 한 번만 구독해 화면 여러 곳에 내려준다.
- `lib/` — `auth.ts`(페이지 로그인 검사), `url.ts`(음악 주소 판별), `recent.ts`(최근 검색어), `api.ts`(봇 RPC).
### 5-3. 플레이어 화면 구성
| 화면 | 진입 | 보이는 것 |
|------|------|-----------|
| 서버 홈 | 서버 선택 직후, 로고·"홈" | 왼쪽: 서버 아이콘·이름·권한·봇이 있는 음성채널, 노래 검색 버튼, (재생 전) 사용 안내, 최근 검색 칩 / 오른쪽: 재생 목록(자동재생 스위치, 지금 곡, 다음 트랙) |
| 검색 결과 | 검색창 Enter, 최근 검색어 | 필터 칩(전체/노래/동영상/Spotify, 개수 표시), 상위 검색결과, 구역별 목록(5곡 + "모두 표시"), 실패 시 "다시 시도" |
| 주소 불러오기 | 유튜브·유튜브 뮤직·스포티파이 주소를 검색창에 붙여넣고 Enter | 곡/재생목록·앨범 구분, "대기열에 추가" → `POST /api/player/playlist` |
| 재생 화면 | 재생바 곡 정보·⌃, 서버 홈의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + 다음 트랙 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 |
- 재생바는 재생 중인 곡이 있을 때만 보인다. 브라우저 탭 제목에 지금 곡(▶/⏸)이 표시된다.
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침/접힘, 좁은 화면은 ☰ 로 서랍.
### 5-4. 버튼·조작 동작 정리
| 위치 | 요소 | 동작 |
|------|------|------|
| 상단 | 검색창 | Enter 로 검색(주소면 불러오기 화면). 누르면 최근 검색어 8개(✕로 개별 삭제) |
| 상단 | 프로필 | 서버 변경 · 로그아웃 |
| 왼쪽 메뉴 | 홈 / 검색 / 서버 변경 | 서버 홈 / 검색창 포커스 / `/servers` |
| 검색 | 곡 행·상위 검색결과 "재생" | `POST /api/player/play`. 요청 중에는 스피너를 띄우고 다른 곡 추가를 막아 중복 추가를 방지 |
| 재생바 | ⏮ | 지금 곡을 처음부터 다시(유튜브 뮤직 동작. 봇에 이전 곡 기록이 없다) |
| 재생바 | ⏯ / ⏭ / 진행바 | 일시정지·재개 / 다음 곡 / 탐색 |
| 재생바 | 스피커 | 클릭 = 음소거/해제, hover 시 볼륨 슬라이더 |
| 재생바 | 섞기 | 다음 트랙 섞기(2곡 이상일 때) |
| 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) |
| 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) |
| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 |
| 다음 트랙 | (목록 길이) | 앞 200곡만 표시하고 곡 수·총 길이는 전체 기준. 나머지는 "외 N곡"으로 표시(스포티파이 재생목록은 최대 5만 곡) |
| 재생 화면 | ⌄ / Esc | 닫기 |
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 |
미지원(봇 기능 없음): 이전 곡으로 돌아가기, 반복 재생, 좋아요/가사. 동작하지 않는 버튼이 되므로 넣지 않았다.
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`.
- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색).
- `components/player/` — `PlayerBar`(재생바/볼륨/탐색), `MainContent`(검색·결과), `QueueSidebar`(대기열).
- `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입.
- `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등).
---

View File

@@ -32,11 +32,6 @@ export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
// 로그인/로그인 오류는 NextAuth 기본 화면 대신 우리 로그인 페이지로 보낸다.
pages: {
signIn: "/login",
error: "/login",
},
callbacks: {
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
async jwt({ token, account, profile }) {

View File

@@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface AutoplayBody {
serverId?: unknown;
}
/** 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) 켜기/끄기 토글 */
export async function POST(request: Request) {
try {
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
const bodyResult = await readJsonBody<AutoplayBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
const { status, body } = await botRpc({
channel: "player:recommend",
payload: { action: "player_recommend", serverId: serverIdResult.value, userId },
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`player/autoplay API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -30,8 +30,6 @@ export async function POST(request: Request) {
userId,
track,
},
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
timeoutMs: 15000,
});
return NextResponse.json(body, { status });
} catch (error) {

View File

@@ -30,8 +30,6 @@ export async function POST(request: Request) {
userId,
playlistUrl: urlResult.value,
},
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
timeoutMs: 15000,
});
return NextResponse.json(body, { status });
} catch (error) {

View File

@@ -5,10 +5,6 @@ import { botRpc, errorResponse, readJsonBody, requireSession, requireString } fr
interface QueueSetBody {
serverId?: unknown;
newQueue?: unknown;
/** 화면에 있던 대기열 앞부분 곡 수(봇은 이 부분만 newQueue로 바꾸고 뒤는 남긴다) */
windowSize?: unknown;
/** 모두 지우기(뒤에 남은 곡과 불러오는 중인 재생목록까지 비움) */
clearAll?: unknown;
}
export async function POST(request: Request) {
@@ -33,8 +29,6 @@ export async function POST(request: Request) {
serverId: serverIdResult.value,
userId,
newQueue,
windowSize: typeof bodyResult.data.windowSize === "number" ? bodyResult.data.windowSize : undefined,
clearAll: bodyResult.data.clearAll === true,
},
timeoutMs: 5000,
});

View File

@@ -1,32 +0,0 @@
import { NextResponse } from "next/server";
import { Logger } from "@/lib/Logger";
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
interface ShuffleBody {
serverId?: unknown;
}
/** 대기열 섞기(현재 곡은 그대로, 다음 트랙만 섞는다) */
export async function POST(request: Request) {
try {
const sessionResult = await requireSession();
if (!sessionResult.ok) return sessionResult.response;
const userId = sessionResult.session.user.id;
const bodyResult = await readJsonBody<ShuffleBody>(request);
if (!bodyResult.ok) return bodyResult.response;
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
if (!serverIdResult.ok) return serverIdResult.response;
const { status, body } = await botRpc({
channel: "queue:shuffle",
payload: { action: "queue_shuffle", serverId: serverIdResult.value, userId },
timeoutMs: 5000,
});
return NextResponse.json(body, { status });
} catch (error) {
Logger.error(`queue/shuffle API error: ${error instanceof Error ? error.message : String(error)}`);
return errorResponse("서버 오류가 발생했습니다.", 500);
}
}

View File

@@ -1,25 +1,10 @@
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
@import "tailwindcss";
/*
* 유튜브 뮤직(웹) 다크 테마 색상.
* - 배경 #030303, 재생바/메뉴 #212121, 강조 빨강 #ff0000
* - 보조 글자는 흰색 70%/50% 투명도(유튜브 뮤직과 같은 방식)
*/
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */
@theme {
--color-background: #030303;
--color-background: #171717; /* neutral-900 */
--color-foreground: #ffffff;
--color-yt-bg: #030303;
--color-yt-raised: #212121;
--color-yt-menu: #282828;
--color-yt-red: #ff0000;
--color-yt-chip: rgb(255 255 255 / 0.1);
--color-yt-chip-hover: rgb(255 255 255 / 0.2);
--color-yt-line: rgb(255 255 255 / 0.1);
--font-sans: Roboto, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", Arial, sans-serif;
}
html {
color-scheme: dark;
}
body {
@@ -27,54 +12,19 @@ body {
color: var(--color-foreground);
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
}
/* 유튜브 뮤직처럼 얇고 어두운 스크롤바 */
* {
scrollbar-width: thin;
scrollbar-color: rgb(255 255 255 / 0.2) transparent;
}
/* 스크롤바 예쁘게 숨기기 (선택 사항) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 0.2);
background: #3f3f46;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(255 255 255 / 0.3);
}
/* 가로 스크롤 칩 줄은 스크롤바를 숨긴다 */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* 재생 중 표시(이퀄라이저 막대) */
@keyframes yt-eq {
0%, 100% { transform: scaleY(0.3); }
50% { transform: scaleY(1); }
}
.yt-eq > span {
display: block;
width: 3px;
height: 14px;
background: currentColor;
transform-origin: bottom;
animation: yt-eq 0.9s ease-in-out infinite;
}
.yt-eq > span:nth-child(2) { animation-delay: -0.3s; }
.yt-eq > span:nth-child(3) { animation-delay: -0.6s; }
.yt-eq.paused > span { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.yt-eq > span { animation: none; transform: scaleY(0.6); }
background: #52525b;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#f00"/><circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" stroke-width="1.1"/><path d="M10 9.2v5.6l4.9-2.8z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 233 B

View File

@@ -1,17 +0,0 @@
import { redirect } from "next/navigation";
import LoginView from "@/components/pages/LoginView";
import { getPageSession, safeCallbackUrl } from "@/lib/auth";
export const metadata = { title: "로그인 | MusicBot" };
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}) {
const { callbackUrl, error } = await searchParams;
const target = safeCallbackUrl(callbackUrl);
const session = await getPageSession();
if (session?.user?.id) redirect(target);
return <LoginView callbackUrl={target} error={error} />;
}

View File

@@ -1,8 +1,67 @@
import { redirect } from "next/navigation";
import { getPageSession } from "@/lib/auth";
"use client";
/** 첫 화면: 로그인 안 했으면 로그인 페이지, 했으면 서버 선택 페이지로 보낸다. */
export default async function Home() {
const session = await getPageSession();
redirect(session?.user?.id ? "/servers" : "/login");
import { useState } from "react";
import TopNav from "@/components/layout/TopNav";
import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent";
import QueueSidebar from "@/components/player/QueueSidebar";
import PlayerBar from "@/components/player/PlayerBar";
import type { DiscordServer } from "@/types/music";
// 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() {
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
const [searchQuery, setSearchQuery] = useState("");
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
const handleHome = () => {
if (selectedServer) {
setViewMode("SERVER_DETAIL");
} else {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}
};
// 검색 실행 시
const handleSearch = (query: string) => {
setSearchQuery(query);
setViewMode("SEARCH_RESULT");
};
// 서버 선택 시
const handleSelectServer = (server: DiscordServer) => {
setSelectedServer(server);
setViewMode("SERVER_DETAIL");
};
return (
<div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
<TopNav
onSearch={handleSearch}
onHome={handleHome}
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
/>
<div className="flex flex-1 overflow-hidden">
<LeftSidebar />
<MainContent
viewMode={viewMode}
setViewMode={setViewMode}
selectedServer={selectedServer}
setSelectedServer={setSelectedServer}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onSelectServer={handleSelectServer}
/>
<QueueSidebar selectedServer={selectedServer} />
</div>
<PlayerBar selectedServer={selectedServer} />
</div>
);
}

View File

@@ -1,16 +0,0 @@
import PlayerApp from "@/components/pages/PlayerApp";
import { requirePageSession } from "@/lib/auth";
export default async function ServerPlayerPage({
params,
searchParams,
}: {
params: Promise<{ serverId: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { serverId } = await params;
const { q } = await searchParams;
await requirePageSession(`/servers/${serverId}`);
// key: 서버를 바꾸면 재생 상태·검색 결과를 깨끗하게 새로 시작한다.
return <PlayerApp key={serverId} serverId={serverId} initialQuery={typeof q === "string" ? q : ""} />;
}

View File

@@ -1,9 +0,0 @@
import ServerPicker from "@/components/pages/ServerPicker";
import { requirePageSession } from "@/lib/auth";
export const metadata = { title: "서버 선택 | MusicBot" };
export default async function ServersPage() {
await requirePageSession("/servers");
return <ServerPicker />;
}

View File

@@ -29,13 +29,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={{ showToast }}>
{children}
{/* 유튜브 뮤직 스낵바: 왼쪽 아래(재생바 위)에 뜨는 어두운 알림 */}
{/* 화면 최상단에 떠있는 실제 토스트 UI */}
{toast && (
<div
role={toast.type === "error" ? "alert" : "status"}
className="fixed bottom-24 left-4 right-4 z-[9999] flex items-center gap-3 rounded bg-yt-menu px-4 py-3 text-sm text-white shadow-2xl ring-1 ring-white/10 sm:left-1/2 sm:right-auto sm:w-max sm:max-w-md sm:-translate-x-1/2"
className={`fixed bottom-10 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full shadow-2xl flex items-center gap-3 z-[9999] animate-in fade-in slide-in-from-bottom-5 duration-300 font-medium text-sm text-white ${
toast.type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`}
>
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
{toast.message}
</div>
)}

View File

@@ -1,85 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { LogOut, Server } from "lucide-react";
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
export function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) {
if (user.image) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.image} alt="" className={`${className} rounded-full object-cover`} />
);
}
return (
<span className={`${className} flex items-center justify-center rounded-full bg-[#7b1fa2] font-medium text-white`} aria-hidden>
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
</span>
);
}
/**
* 프로필 버튼 → 작은 메뉴(서버 변경 · 로그아웃).
* 계정 전환·설정 같은 유튜브 계정 메뉴 항목은 이 봇에 필요 없어 넣지 않는다.
*/
export default function AccountMenu({ showServerSwitch = true }: { showServerSwitch?: boolean }) {
const { data: session, status } = useSession();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 바깥을 누르거나 Esc 로 닫는다.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (status === "loading" || !session?.user) {
return <span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />;
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={`${session.user.name ?? "내"} 계정 메뉴`}
aria-expanded={open}
className="flex h-10 w-10 items-center justify-center rounded-full"
>
<Avatar user={session.user} />
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-56 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
<p className="truncate px-4 pb-2 pt-1 text-sm text-white/70">{session.user.name}</p>
{showServerSwitch && (
<Link
href="/servers"
className="flex h-10 items-center gap-4 px-4 text-sm text-white hover:bg-white/10"
>
<Server size={20} className="text-white/70" aria-hidden />
서버 변경
</Link>
)}
<button
type="button"
onClick={() => signOut({ callbackUrl: "/login" })}
className="flex h-10 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
>
<LogOut size={20} className="text-white/70" aria-hidden />
로그아웃
</button>
</div>
)}
</div>
);
}

View File

@@ -1,148 +1,20 @@
"use client";
import Link from "next/link";
import { ArrowLeftRight, Home, Menu, Search, Volume2 } from "lucide-react";
import type { DiscordServer } from "@/types/music";
import type { ViewMode } from "@/components/pages/PlayerApp";
import { Logo, ServerAvatar } from "@/components/ui";
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
interface LeftSidebarProps {
/** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
expanded: boolean;
/** 좁은 화면에서 서랍처럼 열린 상태 */
drawerOpen: boolean;
onCloseDrawer: () => void;
viewMode: ViewMode;
server: DiscordServer | null;
/** 봇이 들어가 있는 음성채널 이름(재생 중일 때) */
voiceChannelName: string | null;
onHome: () => void;
}
/**
* 유튜브 뮤직 왼쪽 메뉴(가이드): 홈 / 검색 / 서버 변경 + 아래에 지금 조작 중인 서버.
* 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
*/
export default function LeftSidebar({
expanded,
drawerOpen,
onCloseDrawer,
viewMode,
server,
voiceChannelName,
onHome,
}: LeftSidebarProps) {
const focusSearch = () => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT));
type Item = { key: string; label: string; icon: typeof Home; active?: boolean } & (
| { href: string; onClick?: never }
| { onClick: () => void; href?: never }
);
const nav: Item[] = [
{ key: "home", label: "홈", icon: Home, active: viewMode === "HOME", onClick: onHome },
{ key: "search", label: "검색", icon: Search, active: viewMode === "SEARCH", onClick: focusSearch },
{ key: "switch", label: "서버 변경", icon: ArrowLeftRight, href: "/servers" },
];
const itemClass = (active?: boolean) =>
`flex h-12 w-full items-center gap-5 rounded-lg px-4 text-left text-base text-white transition-colors ${active ? "bg-white/10 font-medium" : "hover:bg-white/10"}`;
const miniClass = (active?: boolean) =>
`flex h-[74px] w-full flex-col items-center justify-center gap-1.5 rounded-lg text-[10px] text-white transition-colors ${active ? "bg-white/10" : "hover:bg-white/10"}`;
const renderItem = (item: Item, cls: string, afterClick?: () => void) => {
const Icon = item.icon;
const content = (
<>
<Icon size={24} strokeWidth={item.active ? 2.2 : 1.6} aria-hidden />
{item.label}
</>
);
return item.href ? (
<Link href={item.href} className={cls}>
{content}
</Link>
) : (
<button
type="button"
onClick={() => {
afterClick?.();
item.onClick?.();
}}
aria-current={item.active ? "page" : undefined}
className={cls}
>
{content}
</button>
);
};
/** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */
const full = (afterClick?: () => void) => (
<div className="flex h-full flex-col">
<ul className="flex flex-col gap-1 px-2 pt-2">
{nav.map((item) => (
<li key={item.key}>{renderItem(item, itemClass(item.active), afterClick)}</li>
))}
</ul>
{server && (
<div className="mx-3 mb-4 mt-auto rounded-xl bg-white/[0.06] p-3">
<div className="flex items-center gap-3">
<ServerAvatar server={server} className="h-10 w-10" iconSize={16} size={64} />
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-white">{server.name}</span>
<span className="block truncate text-xs text-white/60">조작 중인 서버</span>
</span>
</div>
<p className="mt-3 flex items-center gap-1.5 text-xs text-white/60">
<Volume2 size={14} className="flex-shrink-0" aria-hidden />
<span className="truncate">
{voiceChannelName ? `${voiceChannelName}에서 재생 중` : "봇이 음성 채널에 없어요"}
</span>
</p>
</div>
)}
</div>
);
import { ListMusic } from "lucide-react";
export default function LeftSidebar() {
return (
<>
{/* 넓은 화면: 고정 메뉴(펼침 240px / 접힘 72px) */}
<aside
className={`hidden flex-shrink-0 border-r border-yt-line bg-yt-bg lg:block ${expanded ? "w-60" : "w-[72px]"}`}
aria-label="메뉴"
>
{expanded ? (
full()
) : (
<ul className="flex flex-col gap-1 px-1 pt-1">
{nav.map((item) => (
<li key={item.key}>{renderItem(item, miniClass(item.active))}</li>
))}
</ul>
)}
</aside>
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
<div className="flex items-center gap-2 text-neutral-400 font-medium">
<ListMusic size={20} />
<span>음악 봇 컨트롤</span>
</div>
{/* 좁은 화면: 서랍 메뉴 */}
{drawerOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<button type="button" aria-label="메뉴 닫기" onClick={onCloseDrawer} className="absolute inset-0 bg-black/60" />
<aside className="absolute inset-y-0 left-0 flex w-64 flex-col bg-yt-raised shadow-2xl" aria-label="메뉴">
<div className="flex h-16 flex-shrink-0 items-center gap-1 px-2">
<button
type="button"
onClick={onCloseDrawer}
aria-label="메뉴 닫기"
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-white/10"
>
<Menu size={22} aria-hidden />
</button>
<Logo className="px-1" />
</div>
<div className="min-h-0 flex-1">{full(onCloseDrawer)}</div>
</aside>
</div>
)}
</>
<hr className="border-neutral-800" />
<p className="text-xs text-neutral-500 leading-relaxed">
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
</p>
</aside>
);
}

View File

@@ -1,224 +1,83 @@
"use client";
import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Logo } from "@/components/ui";
import AccountMenu from "@/components/layout/AccountMenu";
import { isMusicUrl } from "@/lib/url";
import { RECENT_EVENT, RECENT_MAX, readRecent, writeRecent } from "@/lib/recent";
/** 다른 화면(서버 홈의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
import type { DiscordServer } from "@/types/music";
interface TopNavProps {
/** 지금 화면에 걸린 검색어(뒤로가기 등으로 바뀌면 입력칸도 맞춘다) */
query: string;
onSearch: (query: string) => void;
onHome: () => void;
onToggleGuide: () => void;
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
}
/**
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
* - 검색창을 누르면 최근 검색어가 펼쳐진다(유튜브 뮤직과 같은 방식).
* - 좁은 화면은 돋보기 버튼 → 바 전체가 검색창으로 바뀐다.
*/
export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
const [term, setTerm] = useState(query);
const [mobileSearch, setMobileSearch] = useState(false);
const [focused, setFocused] = useState(false);
const [recent, setRecent] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
const { data: session, status } = useSession();
const [term, setTerm] = useState("");
// 화면의 검색어가 바뀌면(뒤로가기·홈) 입력칸도 맞춘다.
useEffect(() => {
setTerm(query);
}, [query]);
useEffect(() => {
const sync = () => setRecent(readRecent());
sync();
window.addEventListener(RECENT_EVENT, sync);
return () => window.removeEventListener(RECENT_EVENT, sync);
}, []);
// 서버 홈의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
useEffect(() => {
const focus = () => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
};
window.addEventListener(FOCUS_SEARCH_EVENT, focus);
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
}, []);
// 검색창 바깥을 누르면 최근 검색어 목록을 닫는다.
useEffect(() => {
if (!focused) return;
const onDown = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setFocused(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [focused]);
const run = (value: string) => {
const q = value.trim();
if (!q) return;
setTerm(q);
// 주소는 최근 검색어에 남기지 않는다(길고 다시 쓸 일이 적다).
if (!isMusicUrl(q)) {
const next = [q, ...recent.filter((r) => r !== q)].slice(0, RECENT_MAX);
setRecent(next);
writeRecent(next);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && term.trim()) {
onSearch(term);
}
onSearch(q);
setFocused(false);
inputRef.current?.blur();
setMobileSearch(false);
};
const removeRecent = (r: string) => {
const next = recent.filter((x) => x !== r);
setRecent(next);
writeRecent(next);
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
const handleHomeClick = () => {
setTerm(""); // 검색창 비우기
onHome(); // 원래 홈 기능 실행
};
const showDropdown = focused && (recent.length > 0 || isMusicUrl(term));
return (
<header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
<nav className="relative flex h-16 items-center gap-2 px-2 sm:px-4">
{/* 좁은 화면 검색 모드: 뒤로 + 검색창이 바 전체를 차지한다 */}
{mobileSearch && (
<div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
<button
type="button"
onClick={() => {
setMobileSearch(false);
setFocused(false);
}}
aria-label="검색 닫기"
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
>
<ArrowLeft size={22} aria-hidden />
<nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
{/* 1. 왼쪽: 로고 */}
<div className="flex items-center gap-2 text-xl font-bold text-white w-52">
<ListMusic className="text-green-500" />
<span>MusicBot</span>
</div>
{/* 2. 가운데: 검색창 & 홈 버튼 */}
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
<button
onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
title="홈으로"
>
<Home size={22} />
</button>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
<input
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
className={`w-full h-10 pl-10 pr-4 rounded-full bg-neutral-800 border border-neutral-700 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/30 transition-all ${!selectedServer ? 'opacity-50 cursor-not-allowed' : 'opacity-100'}`}
/>
</div>
</div>
{/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
<div className="flex items-center justify-end w-auto min-w-[13rem]">
{status === "loading" ? (
<div className="text-sm text-neutral-400">로딩 중...</div>
) : status === "authenticated" && session?.user ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-neutral-800/80 pr-4 pl-1 py-1 rounded-full border border-neutral-700 shadow-sm cursor-default">
<img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
<span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
</div>
<button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
<LogOut size={18} />
</button>
</div>
) : (
<button onClick={() => signIn("discord")} className="cursor-pointer flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752C4] text-white px-5 py-2 rounded-full font-medium transition-colors text-sm shadow-md">
<LogIn size={16} /> Discord 로그인
</button>
)}
{/* 왼쪽: 메뉴 + 로고 (넓은 화면에서는 왼쪽 메뉴 폭에 맞춘다) */}
<div className="flex flex-shrink-0 items-center gap-1 lg:w-[232px]">
<button
type="button"
onClick={onToggleGuide}
aria-label="메뉴"
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-yt-chip"
>
<Menu size={22} aria-hidden />
</button>
<button type="button" onClick={onHome} aria-label="홈으로" className="rounded px-1 py-1">
<Logo />
</button>
</div>
{/* 검색창 */}
<div
ref={boxRef}
className={`min-w-0 flex-1 md:block ${mobileSearch ? "absolute inset-y-0 left-14 right-2 z-20 flex items-center md:static" : "hidden"}`}
>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
run(term);
}}
className="relative w-full max-w-[480px]"
>
<Search className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70" size={20} aria-hidden />
<input
ref={inputRef}
type="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
onFocus={() => setFocused(true)}
onKeyDown={(e) => e.key === "Escape" && setFocused(false)}
placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기"
aria-label="노래 검색"
autoComplete="off"
className={`h-10 w-full border border-white/20 bg-white/15 pl-12 pr-10 text-base text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none [&::-webkit-search-cancel-button]:hidden
${showDropdown ? "rounded-t-lg" : "rounded-lg"}`}
/>
{term && (
<button
type="button"
onClick={() => {
setTerm("");
inputRef.current?.focus();
}}
aria-label="검색어 지우기"
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white/70 hover:text-white"
>
<X size={18} aria-hidden />
</button>
)}
{/* 최근 검색어 / 주소 안내 */}
{showDropdown && (
<div className="absolute inset-x-0 top-10 z-50 overflow-hidden rounded-b-lg border border-t-0 border-white/20 bg-yt-raised py-2 shadow-2xl">
{isMusicUrl(term) ? (
<button
type="submit"
className="flex h-11 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
>
<Link2 size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">이 주소의 곡·재생목록 불러오기</span>
</button>
) : (
recent.map((r) => (
<div key={r} className="group flex h-11 items-center hover:bg-white/10">
<button
type="button"
onClick={() => run(r)}
className="flex h-full min-w-0 flex-1 items-center gap-4 pl-4 text-left text-sm text-white"
>
<Clock size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">{r}</span>
</button>
<button
type="button"
onClick={() => removeRecent(r)}
aria-label={`최근 검색어 ${r} 삭제`}
className="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-white/50 hover:text-white"
>
<X size={16} aria-hidden />
</button>
</div>
))
)}
</div>
)}
</form>
</div>
<div className="flex-1 md:hidden" />
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
aria-label="검색"
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-yt-chip md:hidden"
>
<Search size={22} aria-hidden />
</button>
<AccountMenu />
</div>
</nav>
</header>
</div>
</nav>
);
}

View File

@@ -1,89 +0,0 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { Headphones, ListMusic, Server } from "lucide-react";
import { Logo, Spinner } from "@/components/ui";
/** 디스코드 로고(공식 색상 버튼 안에 넣는 흰색 마크) */
function DiscordMark() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden fill="currentColor">
<path d="M20.32 4.37A19.8 19.8 0 0 0 15.4 2.85a13.8 13.8 0 0 0-.63 1.29 18.4 18.4 0 0 0-5.53 0 13.6 13.6 0 0 0-.64-1.29 19.7 19.7 0 0 0-4.92 1.52C.53 9.05-.32 13.6.1 18.1a19.9 19.9 0 0 0 6.03 3.05c.49-.66.92-1.36 1.29-2.1a12.9 12.9 0 0 1-2.03-.98c.17-.12.34-.25.5-.38a14.2 14.2 0 0 0 12.22 0l.5.38c-.65.38-1.33.71-2.04.98.37.74.8 1.44 1.29 2.1a19.8 19.8 0 0 0 6.03-3.05c.5-5.2-.84-9.71-3.57-13.73ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.97 0c-1.19 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
</svg>
);
}
const ERROR_TEXT: Record<string, string> = {
OAuthCallback: "디스코드 로그인 중 문제가 생겼습니다. 다시 시도해 주세요.",
AccessDenied: "디스코드에서 로그인 권한을 허용하지 않았습니다.",
};
const STEPS = [
{ icon: ListMusic, title: "디스코드로 로그인", desc: "계정 비밀번호는 이 사이트에 저장되지 않아요." },
{ icon: Server, title: "서버 선택", desc: "음악 봇이 들어가 있는 내 서버만 보여요." },
{ icon: Headphones, title: "음성 채널에서 감상", desc: "음성 채널에 들어간 뒤 노래를 검색해 누르면 봇이 재생해요." },
];
/** 로그인 전용 페이지. 로그인해야만 서버 선택·플레이어로 들어갈 수 있다. */
export default function LoginView({ callbackUrl, error }: { callbackUrl: string; error?: string }) {
const [pending, setPending] = useState(false);
const errorText = error ? (ERROR_TEXT[error] ?? "로그인하지 못했습니다. 다시 시도해 주세요.") : null;
return (
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-yt-bg px-5 py-10 text-white">
{/* 은은한 빨간 빛 배경 */}
<div
className="pointer-events-none absolute -top-40 left-1/2 h-[520px] w-[820px] -translate-x-1/2 rounded-full bg-yt-red/20 blur-[120px]"
aria-hidden
/>
<div className="relative w-full max-w-md">
<div className="flex justify-center">
<Logo className="scale-[1.35]" />
</div>
<div className="mt-10 rounded-2xl bg-yt-raised/80 p-6 shadow-2xl ring-1 ring-white/10 backdrop-blur sm:p-8">
<h1 className="text-center text-2xl font-bold">로그인</h1>
<p className="mt-2 text-center text-sm leading-relaxed text-white/70">
디스코드 계정으로 로그인하면 브라우저에서 음악 봇을 조작할 수 있어요.
</p>
{errorText && (
<p role="alert" className="mt-5 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
{errorText}
</p>
)}
<button
type="button"
disabled={pending}
onClick={() => {
setPending(true);
signIn("discord", { callbackUrl });
}}
className="mt-6 flex h-12 w-full items-center justify-center gap-3 rounded-full bg-[#5865F2] text-base font-medium text-white transition-colors hover:bg-[#4752C4] disabled:opacity-70"
>
{pending ? <Spinner size={20} label="디스코드로 이동 중" /> : <DiscordMark />}
{pending ? "디스코드로 이동 중..." : "Discord로 로그인"}
</button>
<ol className="mt-8 flex flex-col gap-4 border-t border-yt-line pt-6">
{STEPS.map(({ icon: Icon, title, desc }, i) => (
<li key={title} className="flex gap-4">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-white">
<Icon size={18} aria-hidden />
</span>
<span>
<span className="block text-sm font-medium">
{i + 1}. {title}
</span>
<span className="block text-sm text-white/60">{desc}</span>
</span>
</li>
))}
</ol>
</div>
</div>
</main>
);
}

View File

@@ -1,164 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import TopNav from "@/components/layout/TopNav";
import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent";
import PlayerBar from "@/components/player/PlayerBar";
import PlayerPage from "@/components/player/PlayerPage";
import { Spinner } from "@/components/ui";
import { cleanArtist, usePlayer } from "@/hooks/usePlayer";
import { useQueue } from "@/hooks/useQueue";
import { setLastServerId, useServers } from "@/hooks/useServers";
import type { DiscordServer } from "@/types/music";
/** 플레이어 안의 화면: 서버 홈 / 검색 결과 */
export type ViewMode = "HOME" | "SEARCH";
/** 주소창의 ?q= 를 읽는다(뒤로가기로 검색 화면을 오갈 수 있게 검색어를 주소에 남긴다) */
function readQuery() {
return new URLSearchParams(window.location.search).get("q") ?? "";
}
/**
* 유튜브 뮤직 구조의 봇 플레이어(서버를 고른 뒤 들어오는 화면):
* 상단 바(☰ 로고 · 검색 · 프로필)
* [왼쪽 메뉴] [본문: 서버 홈 / 검색 결과] ← 재생 화면(⌃)은 본문 위를 덮는다
* 하단 재생바
*/
export default function PlayerApp({ serverId, initialQuery }: { serverId: string; initialQuery: string }) {
const router = useRouter();
const { status } = useSession();
const [searchQuery, setSearchQuery] = useState(initialQuery);
const viewMode: ViewMode = searchQuery ? "SEARCH" : "HOME";
// 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍
const [guideExpanded, setGuideExpanded] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { servers, loaded, isFetching, error } = useServers();
const found = servers.find((s) => s.id === serverId) ?? null;
// 목록을 받기 전에도 재생 상태 구독은 바로 시작한다(봇이 서버 멤버 여부를 다시 확인한다).
const server: DiscordServer = useMemo(
() => found ?? { id: serverId, name: "", icon: null, owner: false, permissions: "0" },
[found, serverId],
);
// 재생 상태·대기열은 재생바, 재생 화면, 서버 홈이 같이 쓰므로 여기서 한 번만 구독한다.
const player = usePlayer(server);
const queue = useQueue(server);
// 세션이 만료되면 로그인 페이지로
useEffect(() => {
if (status === "unauthenticated") router.replace(`/login?callbackUrl=${encodeURIComponent(`/servers/${serverId}`)}`);
}, [status, router, serverId]);
useEffect(() => {
if (found) setLastServerId(found.id);
}, [found]);
// 뒤로/앞으로 가기로 검색 화면을 오간다.
useEffect(() => {
const onPop = () => setSearchQuery(readQuery());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// 탭 제목에 지금 곡을 보여준다(다른 탭에 있어도 무슨 곡인지 알 수 있게).
useEffect(() => {
const t = player.track?.info;
const base = found?.name ? `${found.name} | MusicBot` : "MusicBot";
document.title = t?.title ? `${player.isPaused ? "⏸" : "▶"} ${t.title} · ${cleanArtist(t.author)} | MusicBot` : base;
}, [player.track, player.isPaused, found]);
const goHome = () => {
setIsPlayerOpen(false);
if (searchQuery) {
setSearchQuery("");
window.history.pushState(null, "", `/servers/${serverId}`);
}
};
const handleSearch = (query: string) => {
setIsPlayerOpen(false);
setSearchQuery(query);
window.history.pushState(null, "", `/servers/${serverId}?q=${encodeURIComponent(query)}`);
};
const handleToggleGuide = () => {
// 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기
if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v);
else setDrawerOpen(true);
};
const closePlayer = useCallback(() => setIsPlayerOpen(false), []);
// 목록을 받았는데 이 서버가 없으면(봇이 없거나 내가 멤버가 아님) 서버 선택으로 안내
if (loaded && !isFetching && !found) {
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-yt-bg px-6 text-center text-white">
<p className="text-xl font-bold">이 서버를 열 수 없어요</p>
<p className="max-w-sm text-sm leading-relaxed text-white/60">
{error ?? "음악 봇이 없는 서버이거나, 내가 들어가 있지 않은 서버예요."}
</p>
<Link href="/servers" className="mt-2 h-10 rounded-full bg-white px-5 text-sm font-medium leading-10 text-black hover:bg-white/85">
서버 선택으로
</Link>
</main>
);
}
if (status !== "authenticated") {
return (
<main className="flex min-h-dvh items-center justify-center bg-yt-bg">
<Spinner />
</main>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-yt-bg font-sans text-white">
<TopNav query={searchQuery} onSearch={handleSearch} onHome={goHome} onToggleGuide={handleToggleGuide} />
<div className="relative flex min-h-0 flex-1">
<LeftSidebar
expanded={guideExpanded}
drawerOpen={drawerOpen}
onCloseDrawer={() => setDrawerOpen(false)}
viewMode={viewMode}
server={found}
voiceChannelName={player.voiceChannelName}
onHome={goHome}
/>
<div className="relative flex min-w-0 flex-1">
<MainContent
viewMode={viewMode}
server={server}
searchQuery={searchQuery}
onSearch={handleSearch}
onOpenPlayer={() => setIsPlayerOpen(true)}
showQueue={!isPlayerOpen}
player={player}
queue={queue}
/>
{isPlayerOpen && <PlayerPage player={player} queue={queue} onClose={closePlayer} />}
</div>
</div>
{/* 유튜브 뮤직처럼 재생 중인 곡이 있을 때만 재생바를 띄운다 */}
{player.track && (
<PlayerBar
player={player}
onShuffle={queue.shuffle}
canShuffle={queue.total >= 2}
isPlayerOpen={isPlayerOpen}
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
/>
)}
</div>
);
}

View File

@@ -1,133 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { RefreshCw, Search } from "lucide-react";
import AccountMenu from "@/components/layout/AccountMenu";
import { Logo, ServerAvatar, Spinner } from "@/components/ui";
import { getLastServerId, permissionLabel, setLastServerId, useServers } from "@/hooks/useServers";
/** 서버가 이만큼 넘으면 이름 검색 칸을 보여준다 */
const FILTER_THRESHOLD = 8;
/** 서버 선택 전용 페이지. 여기서 서버를 골라야 플레이어로 들어간다. */
export default function ServerPicker() {
const router = useRouter();
const { data: session, status } = useSession();
const { servers, isFetching, error, loaded, refresh } = useServers();
const [filter, setFilter] = useState("");
const [lastId, setLastId] = useState<string | null>(null);
// 세션이 만료됐으면 로그인 페이지로
useEffect(() => {
if (status === "unauthenticated") router.replace("/login?callbackUrl=/servers");
}, [status, router]);
// localStorage 는 브라우저에서만 읽을 수 있다(서버 렌더와 어긋나지 않게 마운트 뒤에 읽음).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastId(getLastServerId());
}, []);
// 최근 사용한 서버를 맨 앞으로
const ordered = useMemo(() => {
const list = [...servers];
const i = list.findIndex((s) => s.id === lastId);
if (i > 0) list.unshift(list.splice(i, 1)[0]);
const q = filter.trim().toLowerCase();
return q ? list.filter((s) => s.name.toLowerCase().includes(q)) : list;
}, [servers, lastId, filter]);
return (
<div className="min-h-dvh bg-yt-bg text-white">
<header className="sticky top-0 z-40 border-b border-yt-line bg-yt-bg/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between px-4 sm:px-8">
<Logo />
<AccountMenu showServerSwitch={false} />
</div>
</header>
<main className="mx-auto max-w-[1200px] px-4 pb-16 pt-8 sm:px-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm text-white/70">{session?.user?.name ? `${session.user.name}님, 안녕하세요` : " "}</p>
<h1 className="mt-1 text-[28px] font-bold leading-tight sm:text-[32px]">어느 서버에서 들을까요?</h1>
<p className="mt-2 text-sm text-white/60">음악 봇이 들어가 있는 내 서버만 보여요.</p>
</div>
<button
type="button"
onClick={refresh}
disabled={isFetching}
className="flex h-9 items-center gap-2 rounded-full border border-white/20 px-4 text-sm font-medium text-white transition-colors hover:bg-white/10 disabled:opacity-50"
>
<RefreshCw size={16} className={isFetching ? "animate-spin" : ""} aria-hidden />
새로고침
</button>
</div>
{servers.length > FILTER_THRESHOLD && (
<div className="relative mt-6 max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/60" size={18} aria-hidden />
<input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="서버 이름으로 찾기"
aria-label="서버 이름으로 찾기"
className="h-10 w-full rounded-lg border border-white/20 bg-white/10 pl-10 pr-3 text-sm text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none"
/>
</div>
)}
{error && (
<p role="alert" className="mt-6 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
{error} 잠시 뒤 새로고침을 눌러 주세요.
</p>
)}
<div className="mt-8">
{!loaded ? (
<div className="flex justify-center py-24">
<Spinner label="서버 목록을 불러오는 중" />
</div>
) : servers.length === 0 ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-20 text-center">
<p className="text-lg font-medium">표시할 서버가 없어요</p>
<p className="text-sm leading-relaxed text-white/60">
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있어요.
봇을 서버에 초대한 뒤 새로고침을 눌러 주세요.
</p>
</div>
) : ordered.length === 0 ? (
<p className="py-16 text-center text-sm text-white/60">&ldquo;{filter}&rdquo;과(와) 일치하는 서버가 없어요.</p>
) : (
<ul className="grid grid-cols-3 gap-x-3 gap-y-6 sm:gap-x-4 sm:gap-y-8 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{ordered.map((server) => (
<li key={server.id}>
<Link
href={`/servers/${server.id}`}
onClick={() => setLastServerId(server.id)}
className="group block text-center focus:outline-none"
>
<span className="relative mb-3 block">
<ServerAvatar server={server} className="aspect-square w-full" iconSize={40} size={256} />
<span className="absolute inset-0 rounded-full transition-colors group-hover:bg-black/30 group-focus-visible:ring-2 group-focus-visible:ring-white" />
{server.id === lastId && (
<span className="absolute left-1/2 top-2 -translate-x-1/2 rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-black shadow">
최근
</span>
)}
</span>
<span className="block truncate text-base font-medium">{server.name}</span>
<span className="block truncate text-sm text-white/60">{permissionLabel(server)}</span>
</Link>
</li>
))}
</ul>
)}
</div>
</main>
</div>
);
}

View File

@@ -1,416 +1,329 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Clock, Headphones, Link2, ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
import type { ViewMode } from "@/components/pages/PlayerApp";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider";
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
import QueueList from "@/components/player/QueueList";
import { ServerAvatar, Spinner, Thumb } from "@/components/ui";
import { formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
import { permissionLabel } from "@/hooks/useServers";
import { RECENT_EVENT, readRecent } from "@/lib/recent";
import { isCollectionUrl, isMusicUrl } from "@/lib/url";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
type SourceKey = keyof SearchResults;
type Filter = "all" | SourceKey;
/** 검색 결과 구역(유튜브 뮤직 검색 화면의 "노래 / 동영상" 구성 + 스포티파이) */
const SOURCES: { key: SourceKey; title: string; kind: string; wide: boolean }[] = [
{ key: "youtubeMusic", title: "노래", kind: "노래", wide: false },
{ key: "youtubeVideo", title: "동영상", kind: "동영상", wide: true },
{ key: "spotify", title: "Spotify", kind: "Spotify", wide: false },
];
/** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */
const PREVIEW_COUNT = 5;
const EMPTY_RESULTS: SearchResults = { spotify: [], youtubeMusic: [], youtubeVideo: [] };
interface MainContentProps {
viewMode: ViewMode;
server: DiscordServer;
setViewMode: (mode: ViewMode) => void;
selectedServer: DiscordServer | null;
setSelectedServer: (server: DiscordServer | null) => void;
searchQuery: string;
onSearch: (query: string) => void;
onOpenPlayer: () => void;
/** 재생 화면이 열려 있으면 서버 홈의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
showQueue: boolean;
player: PlayerState;
queue: QueueState;
setSearchQuery: (query: string) => void;
onSelectServer: (server: DiscordServer) => void;
}
export default function MainContent({
viewMode,
server,
setViewMode,
selectedServer,
setSelectedServer,
searchQuery,
onSearch,
onOpenPlayer,
showQueue,
player,
queue,
setSearchQuery,
onSelectServer,
}: MainContentProps) {
const { data: session } = useSession();
const { data: session, status } = useSession();
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast();
const [searchResults, setSearchResults] = useState<SearchResults>(EMPTY_RESULTS);
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResults>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("all");
// 같은 곡을 연달아 눌러 중복으로 쌓이지 않게, 요청 중인 항목을 표시한다.
const [pendingKey, setPendingKey] = useState<string | null>(null);
const [recent, setRecent] = useState<string[]>([]);
const [retry, setRetry] = useState(0);
const isUrlQuery = isMusicUrl(searchQuery);
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
if (!selectedServer) {
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
useEffect(() => {
const sync = () => setRecent(readRecent());
sync();
window.addEventListener(RECENT_EVENT, sync);
return () => window.removeEventListener(RECENT_EVENT, sync);
}, []);
/** 곡 하나(player_play) 또는 주소(player_playlist: 곡·재생목록·앨범 모두)를 대기열에 넣는다. */
const addToQueue = async (key: string, body: Record<string, unknown>, endpoint: string, okText: string) => {
if (!session?.user?.id) {
const userId = session?.user?.id;
if (!userId) {
showToast("로그인이 필요합니다.", "error");
return;
}
if (pendingKey) return;
setPendingKey(key);
let endpoint = "";
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
} else if (actionType === 'player_playlist') {
endpoint = "/api/player/playlist";
bodyData.playlistUrl = playlistUrl;
}
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: server.id, ...body }),
body: JSON.stringify(bodyData),
});
const data = await res.json().catch(() => ({}));
const data = await res.json();
if (res.ok && data.success) {
showToast(okText, "success");
// 🌟 성공 시 초록색 알림
showToast("재생목록에 곡을 추가했습니다!", "success");
} else {
// 🌟 에러 시 빨간색 알림
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
}
} catch (error) {
console.error("Music Action Error:", error);
// 🌟 네트워크 에러 시 알림
showToast('서버와 통신할 수 없습니다.', "error");
} finally {
setPendingKey(null);
}
};
const addTrack = (track: SearchTrack, key: string) =>
addToQueue(key, { track }, "/api/player/play", `"${track.title}"을(를) 대기열에 추가했어요.`);
const addUrl = (url: string) =>
addToQueue(
"url",
{ playlistUrl: url },
"/api/player/playlist",
isCollectionUrl(url) ? "재생목록을 대기열에 추가했어요. 나머지 곡은 이어서 채워져요." : "대기열에 추가했어요.",
);
const getPermissionLabel = (server: DiscordServer | null) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
try {
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
}
};
useEffect(() => {
if (viewMode !== "SEARCH" || !searchQuery || isMusicUrl(searchQuery)) return;
let cancelled = false;
// 새 검색마다 결과·탭을 초기화하는 의도적 패턴
setIsSearching(true);
setSearchError(null);
setFilter("all");
setSearchResults(EMPTY_RESULTS);
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json().catch(() => null);
if (cancelled) return;
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
setSearchError((data && (data.error || data.message)) || "검색하지 못했습니다.");
if (status === "authenticated") {
setIsFetching(true);
const cached = sessionStorage.getItem("filtered_servers");
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setIsFetching(false);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem("filtered_servers");
}
})
.catch(() => !cancelled && setSearchError("서버와 통신할 수 없습니다."))
.finally(() => !cancelled && setIsSearching(false));
// 결과가 늦게 와도 이미 다른 검색어로 넘어갔으면 버린다.
return () => {
cancelled = true;
};
}, [viewMode, searchQuery, retry]);
}
/**
* 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
* 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
*/
const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean, sourceKey: string, index: number) => {
const key = `${sourceKey}:${track.videoId || track.id || track.url || index}`;
const pending = pendingKey === key;
const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
fetch("/api/servers")
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
setServers(data);
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
}
})
.finally(() => setIsFetching(false));
}
}, [status]);
useEffect(() => {
if (viewMode === "SEARCH_RESULT" && searchQuery) {
setIsSearching(true);
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json();
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
console.error("Search Error:", data);
}
})
.catch(err => console.error("Fetch Error:", err))
.finally(() => setIsSearching(false));
}
}, [viewMode, searchQuery]);
if (status === "unauthenticated") {
return (
<li key={key} className="border-b border-yt-line last:border-b-0">
<button
type="button"
onClick={() => addTrack(track, key)}
disabled={!!pendingKey}
aria-label={`${track.title} 대기열에 추가`}
className="group flex w-full items-center gap-4 py-2 text-left focus:outline-none focus-visible:bg-white/10 disabled:cursor-wait"
>
<span className={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
<Thumb src={track.thumbnail} wide={wide} className="h-full w-full" />
{/* 썸네일 위 재생 아이콘: 마우스면 hover 때, 터치 화면이면 항상 */}
<span className={`absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity ${pending ? "opacity-100" : "opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100 pointer-coarse:bg-black/30"}`}>
{pending ? <Spinner size={20} label="추가 중" /> : <Play fill="white" stroke="white" size={20} aria-hidden />}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-base text-white">{track.title}</span>
<span className="block truncate text-sm text-white/70">{meta}</span>
</span>
<span
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white/70 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:hidden"
aria-hidden
>
<ListPlus size={22} />
</span>
</button>
</li>
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
<Server size={48} className="text-neutral-500 mb-4" />
<h2 className="text-2xl font-bold">로그인이 필요합니다</h2>
<p className="text-neutral-400">서비스를 이용하시려면 디스코드 로그인을 해주세요.</p>
</main>
);
};
}
const counts: Record<SourceKey, number> = {
youtubeMusic: searchResults.youtubeMusic.length,
youtubeVideo: searchResults.youtubeVideo.length,
spotify: searchResults.spotify.length,
};
const hasAnyResults = counts.youtubeMusic + counts.youtubeVideo + counts.spotify > 0;
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
// 상위 검색결과: 유튜브 뮤직 → 스포티파이 → 영상 순으로 첫 곡
const topKey = (["youtubeMusic", "spotify", "youtubeVideo"] as const).find((k) => counts[k] > 0);
const topSource = SOURCES.find((s) => s.key === topKey);
const topTrack = topSource ? searchResults[topSource.key][0] : undefined;
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} />}
<button
onClick={(e) => {
e.stopPropagation();
handleMusicAction('player_play', track);
}}
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="바로 재생"
>
<Play fill="black" stroke="black" size={16} />
</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 (
<main className="relative flex-1 overflow-y-auto bg-yt-bg">
<div className="mx-auto w-full max-w-[1200px] px-4 pb-32 pt-6 sm:px-8 lg:px-12">
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
{/* 서버 홈 — 유튜브 뮤직 앨범 페이지 형태(왼쪽 서버 정보, 오른쪽 재생 목록) */}
{viewMode === "HOME" && (
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-12">
<section className="flex flex-col items-center text-center lg:sticky lg:top-6 lg:w-[320px] lg:flex-shrink-0">
<ServerAvatar
server={server}
rounded="rounded-lg"
className="h-32 w-32 shadow-2xl sm:h-60 sm:w-60"
iconSize={64}
size={512}
/>
<h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{server.name || "서버"}</h1>
<p className="mt-2 text-sm text-white/70">디스코드 서버 • 내 권한: {permissionLabel(server)}</p>
<p className="mt-1 flex items-center justify-center gap-1.5 text-sm text-white/60">
<Headphones size={14} aria-hidden />
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "봇이 아직 음성 채널에 없어요"}
</p>
<div className="mt-6 flex items-center gap-3">
<button
type="button"
onClick={() => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT))}
className="flex h-10 items-center gap-2 rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/85"
{/* 화면 1: 서버 목록 */}
{viewMode === "SERVER_LIST" && (
<>
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
{isFetching && servers.length === 0 ? (
<div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p>서버 목록을 불러오는 중...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{servers.map((server) => (
<div
key={server.id}
onClick={() => onSelectServer(server)}
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"
>
<Search size={18} aria-hidden />
노래 검색
</button>
<button
type="button"
onClick={onOpenPlayer}
aria-label="재생 화면 열기"
title="재생 화면"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 text-white transition-colors hover:bg-white/10"
>
<ListMusic size={20} aria-hidden />
</button>
</div>
{/* 처음 쓰는 사람을 위한 안내: 아무것도 재생 중이 아닐 때만 */}
{!player.track && (
<ol className="mt-6 w-full max-w-sm space-y-2 rounded-xl bg-white/[0.06] p-4 text-left text-sm text-white/80">
<li>1. 디스코드에서 <strong className="text-white">음성 채널에 먼저 입장</strong>해 주세요.</li>
<li>2. 위 검색창에서 노래를 찾거나 유튜브·스포티파이 <strong className="text-white">주소를 붙여넣고</strong> Enter.</li>
<li>3. 곡을 누르면 봇이 들어와서 바로 재생해요.</li>
</ol>
)}
{/* 최근 검색: 다시 찾기 쉽게 칩으로 */}
{recent.length > 0 && (
<div className="mt-6 w-full max-w-sm text-left">
<p className="mb-2 flex items-center gap-1.5 text-xs font-medium text-white/60">
<Clock size={14} aria-hidden /> 최근 검색
</p>
<div className="flex flex-wrap gap-2">
{recent.map((r) => (
<button
key={r}
type="button"
onClick={() => onSearch(r)}
className="h-8 max-w-full truncate rounded-lg bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover"
>
{r}
</button>
))}
<div className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
{server.icon ? (
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
) : (
<Server size={24} className="text-neutral-500" />
)}
</div>
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
</div>
))}
</div>
)}
</>
)}
{/* 화면 2: 서버 상세 대시보드 */}
{viewMode === "SERVER_DETAIL" && selectedServer && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<button onClick={() => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
서버 목록으로 돌아가기
</button>
<div className="flex items-end gap-6 mb-8">
<div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
{selectedServer.icon ? (
<img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
) : <Server size={64} className="text-neutral-500" />}
</div>
<div>
<span className="text-sm font-bold uppercase text-neutral-400 tracking-wider">서버 대시보드</span>
<h1 className="text-5xl font-black mt-2 mb-4">{selectedServer.name}</h1>
<p className="text-neutral-400">이 서버에서 음악 봇이 활발하게 작동 중입니다.</p>
</div>
</div>
<div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> 상세 정보</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
<p className="text-neutral-500 mb-1">서버 고유 ID</p>
<p className="font-mono text-white">{selectedServer.id}</p>
</div>
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
<p className="text-neutral-500 mb-1">나의 서버 권한</p>
<p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
</div>
</div>
</div>
</div>
)}
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
{viewMode === "SEARCH_RESULT" && (
<div>
<h2 className="text-2xl font-bold mb-2">&ldquo;{searchQuery}&rdquo; 검색 결과</h2>
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
{isSearching ? (
<div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p>노래를 찾는 중입니다...</p>
</div>
) : !hasAnyResults ? (
<div className="flex flex-col items-center py-20 text-neutral-500">
<SearchX size={48} className="mb-4" />
<p>검색 결과가 없습니다.</p>
</div>
) : (
<div className="flex flex-col gap-10 pb-10">
{/* 카테고리 1: Spotify */}
{searchResults.spotify.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
<Disc size={24} /> Spotify 결과
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.spotify.map(renderTrackCard)}
</div>
</section>
)}
</section>
<section className="min-w-0 flex-1">
{showQueue && <QueueList player={player} queue={queue} variant="page" />}
</section>
</div>
)}
{/* 카테고리 2: YouTube Music */}
{searchResults.youtubeMusic.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
<Music size={24} /> YouTube Music
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeMusic.map(renderTrackCard)}
</div>
</section>
)}
{/* 카테고리 3: YouTube Video */}
{searchResults.youtubeVideo.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeVideo.map(renderTrackCard)}
</div>
</section>
)}
{/* 주소를 붙여넣은 경우: 검색 대신 바로 불러오기 카드 */}
{viewMode === "SEARCH" && isUrlQuery && (
<section className="mx-auto max-w-2xl">
<h2 className="mb-4 text-2xl font-bold">주소에서 불러오기</h2>
<div className="rounded-lg bg-white/[0.08] p-5">
<div className="flex items-start gap-4">
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
<Link2 size={22} aria-hidden />
</span>
<div className="min-w-0">
<p className="font-medium">{isCollectionUrl(searchQuery) ? "재생목록 · 앨범" : "곡 주소"}</p>
<p className="mt-1 break-all text-sm text-white/60">{searchQuery}</p>
</div>
</div>
<button
type="button"
onClick={() => addUrl(searchQuery)}
disabled={!!pendingKey}
className="mt-5 flex h-10 items-center gap-2 rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
>
{pendingKey === "url" ? <Spinner size={18} label="불러오는 중" /> : <ListPlus size={18} aria-hidden />}
{pendingKey === "url" ? "불러오는 중..." : "대기열에 추가"}
</button>
<p className="mt-3 text-xs text-white/50">유튜브·유튜브 뮤직·스포티파이의 곡, 재생목록, 앨범 주소를 지원해요.</p>
</div>
</section>
)}
{/* 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
{viewMode === "SEARCH" && !isUrlQuery && (
<div>
<div className="no-scrollbar -mx-4 mb-6 flex gap-3 overflow-x-auto px-4 sm:mx-0 sm:px-0">
{([{ key: "all", title: "전체" } as const, ...SOURCES]).map((chip) => {
const active = filter === chip.key;
const disabled = chip.key !== "all" && counts[chip.key] === 0;
return (
<button
key={chip.key}
type="button"
onClick={() => setFilter(chip.key)}
disabled={disabled || isSearching}
aria-pressed={active}
className={`h-8 flex-shrink-0 rounded-lg px-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40
${active ? "bg-white text-black" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
>
{chip.title}
{chip.key !== "all" && counts[chip.key] > 0 && <span className="ml-1 opacity-60">{counts[chip.key]}</span>}
</button>
);
})}
</div>
{isSearching ? (
<div className="flex flex-col items-center gap-4 py-24 text-white/70">
<Spinner label="노래를 찾는 중" />
<p className="text-sm">&ldquo;{searchQuery}&rdquo; 검색 중...</p>
</div>
) : searchError ? (
<div className="flex flex-col items-center gap-3 py-24 text-center">
<SearchX size={48} className="text-white/50" aria-hidden />
<p className="text-base text-white">{searchError}</p>
<button
type="button"
onClick={() => setRetry((n) => n + 1)}
className="h-9 rounded-full border border-white/20 px-4 text-sm hover:bg-white/10"
>
다시 시도
</button>
</div>
) : !hasAnyResults ? (
<div className="flex flex-col items-center gap-3 py-24 text-center">
<SearchX size={48} className="text-white/50" aria-hidden />
<p className="text-base text-white">&ldquo;{searchQuery}&rdquo;에 대한 검색 결과가 없습니다.</p>
<p className="text-sm text-white/50">철자를 확인하거나 다른 검색어를 입력해 보세요.</p>
</div>
) : (
<div className="flex flex-col gap-10">
{filter === "all" && topTrack && topSource && (
<section>
<h2 className="mb-4 text-2xl font-bold">상위 검색결과</h2>
<div className="flex flex-col gap-4 rounded-lg bg-white/[0.08] p-4 sm:flex-row sm:items-center sm:gap-6 sm:p-5">
<div className="flex min-w-0 items-center gap-4 sm:gap-6">
<Thumb
src={topTrack.thumbnail}
wide={topSource.wide}
className={topSource.wide ? "h-[88px] w-[156px]" : "h-[88px] w-[88px]"}
iconSize={32}
/>
<div className="min-w-0">
<p className="line-clamp-2 text-xl font-bold leading-tight sm:text-2xl">{topTrack.title}</p>
<p className="mt-1 truncate text-sm text-white/70">
{[topSource.kind, topTrack.artist, topTrack.duration ? formatTime(topTrack.duration) : null]
.filter(Boolean)
.join(" • ")}
</p>
</div>
</div>
<div className="flex gap-2 sm:ml-auto">
<button
type="button"
onClick={() => addTrack(topTrack, "top")}
disabled={!!pendingKey}
className="flex h-9 items-center gap-2 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
>
{pendingKey === "top" ? <Spinner size={16} label="추가 중" /> : <Play size={18} fill="currentColor" aria-hidden />}
재생
</button>
</div>
</div>
</section>
)}
{SOURCES.filter((s) => (filter === "all" || filter === s.key) && counts[s.key] > 0).map((s) => {
const list = searchResults[s.key];
const shown = filter === "all" ? list.slice(0, PREVIEW_COUNT) : list;
return (
<section key={s.key}>
<div className="mb-2 flex items-end justify-between gap-4">
<h2 className="text-2xl font-bold">{s.title}</h2>
{filter === "all" && list.length > PREVIEW_COUNT && (
<button
type="button"
onClick={() => setFilter(s.key)}
className="h-8 flex-shrink-0 rounded-full border border-white/20 px-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
>
모두 표시
</button>
)}
</div>
<ul>{shown.map((t, i) => renderTrackRow(t, s.kind, s.wide, s.key, i))}</ul>
</section>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</main>
);
}

View File

@@ -1,222 +1,386 @@
"use client";
import { ChevronDown, ChevronUp, Pause, Play, Shuffle, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
import { Thumb } from "@/components/ui";
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import { useState, useEffect, useCallback, useRef } from "react";
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
import { useSession } from "next-auth/react";
import type { DiscordServer, Track } from "@/types/music";
interface PlayerBarProps {
player: PlayerState;
onShuffle: () => void;
canShuffle: boolean;
isPlayerOpen: boolean;
onTogglePlayer: () => void;
selectedServer: DiscordServer | null;
}
/** 진행바(유튜브 뮤직: 재생바 위쪽 가장자리의 얇은 빨간 선, 마우스를 올리면 굵어지고 손잡이가 보인다) */
export function SeekBar({ player, className = "", thick = false }: { player: PlayerState; className?: string; thick?: boolean }) {
const { position, duration, canControl, dragSeek, commitSeek } = player;
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const { data: session } = useSession();
// 재생 상태 관리
const [track, setTrack] = useState<Track | null>(null);
const [botPlayer, setBotPlayer] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPaused, setIsPaused] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(50);
// 시간 및 진행도 관리
const [position, setPosition] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
// 1. 현재 재생 상태 불러오기 (player_now)
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/player/now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
const data = await res.json();
if (res.ok && data.success && data.track) {
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
setIsPlaying(false);
setPosition(0);
setDuration(0);
}
} catch (error) {
console.error("재생 정보 불러오기 실패:", error);
}
}, [selectedServer, session]);
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
const isPausedRef = useRef(isPaused);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
useEffect(() => {
if (!isPlaying) return;
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 15000);
return () => {
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
// ================= [ 컨트롤러 액션 ] =================
// 재생/일시정지 토글 (player_pause)
const handleTogglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
isPaused: nextPaused, // boolean 그대로 전송
})
});
const data = await res.json();
if (res.ok && data.success) {
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
// 실패 시 롤백
setIsPaused(!nextPaused);
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const handleSkip = async () => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
setTimeout(fetchNowPlaying, 500);
} catch (error) {
console.error("스킵 에러:", error);
}
};
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
isDragging.current = false;
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
const newPosition = Number(e.currentTarget.value);
setPosition(newPosition);
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
seek: newPosition,
})
});
} catch (error) {
console.error("시간 이동 에러:", error);
}
};
// 볼륨 조절 (player_volume)
const handleVolumeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedServer) return;
setVolume(Number(e.target.value)); // UI 즉시 반영
};
// 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
const finalVolume = Number(e.currentTarget.value);
setVolume(finalVolume); // UI 즉시 반영
try {
const res = await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
volume: finalVolume,
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
} catch (error) {
console.error("볼륨 조절 에러:", error);
setVolume(prevVolumeRef.current); // 실패 시 롤백
}
};
// 시간 포맷 변환 함수 (ms -> mm:ss)
const formatTime = (ms: number) => {
if (!ms || isNaN(ms)) return "00:00";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// 진행도 퍼센트 계산 (0 ~ 100)
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
return (
<div className={`group relative flex h-4 items-center ${className}`}>
<input
type="range"
min={0}
max={duration || 100}
value={position}
disabled={!canControl}
aria-label="재생 위치"
aria-valuetext={`${formatTime(position)} / ${formatTime(duration)}`}
onChange={(e) => dragSeek(Number(e.target.value))}
onMouseUp={(e) => commitSeek(Number(e.currentTarget.value))}
onTouchEnd={(e) => commitSeek(Number(e.currentTarget.value))}
onKeyUp={(e) => commitSeek(Number(e.currentTarget.value))}
className="absolute inset-0 z-20 h-full w-full cursor-pointer opacity-0 disabled:cursor-default"
/>
<div
className={`pointer-events-none absolute inset-x-0 z-10 bg-white/20 transition-[height] ${thick ? "h-1 rounded-full" : "h-0.5 group-hover:h-1"}`}
>
<div className={`h-full bg-yt-red ${thick ? "rounded-full" : ""}`} style={{ width: `${percent}%` }} />
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
{/* 1. 노래 정보 영역 */}
<div className="flex items-center gap-4 w-1/4">
{track ? (
<>
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
{track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
</div>
<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>
</>
) : (
// 재생 중인 곡이 없을 때 빈 공간
<div className="flex items-center gap-4 text-neutral-600">
<div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
<span className="text-sm">재생 중인 곡 없음</span>
</div>
)}
</div>
{canControl && (
<div
className={`pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-yt-red transition-opacity ${thick ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
style={{ left: `${percent}%` }}
/>
)}
</div>
);
}
/**
* 유튜브 뮤직 하단 재생바.
* 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
* 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
*/
export default function PlayerBar({ player, onShuffle, canShuffle, isPlayerOpen, onTogglePlayer }: PlayerBarProps) {
const {
track, isPaused, volume, position, duration, canControl, isVolumeDragging,
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
} = player;
const title = track?.info?.title || "재생 중인 곡 없음";
const artist = cleanArtist(track?.info?.author);
const percent = duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
const playButton = (size: number, className: string) => (
<button
type="button"
onClick={togglePause}
disabled={!canControl}
aria-label={isPaused ? "재생" : "일시정지"}
title={isPaused ? "재생 (Space)" : "일시정지 (Space)"}
className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
>
{isPaused ? <Play size={size} fill="currentColor" aria-hidden /> : <Pause size={size} fill="currentColor" aria-hidden />}
</button>
);
const nextButton = (className: string) => (
<button
type="button"
onClick={skip}
disabled={!canControl}
aria-label="다음 곡"
title="다음 곡 (Ctrl+→)"
className={`flex items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30 ${className}`}
>
<SkipForward size={24} fill="currentColor" aria-hidden />
</button>
);
return (
<footer className="relative z-50 flex-shrink-0 bg-yt-raised">
{/* ===== 넓은 화면 ===== */}
<SeekBar player={player} className="absolute inset-x-0 -top-2 hidden md:flex" />
<div className="hidden h-[72px] items-center gap-4 px-4 md:flex">
{/* 1. 재생 조작 + 시간 */}
<div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
<button
type="button"
onClick={restart}
disabled={!canControl}
title="처음부터 다시 재생"
aria-label="처음부터 다시 재생"
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30"
>
<SkipBack size={24} fill="currentColor" aria-hidden />
{/* 2. 중앙 컨트롤러 영역 */}
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
<div className="flex items-center gap-6">
{/* 이전 곡 (비활성화 상태) */}
<button className="text-neutral-600 cursor-not-allowed">
<SkipBack size={20} />
</button>
{/* 재생/일시정지 버튼 */}
<button
onClick={handleTogglePause}
disabled={!botPlayer || !isPlaying || !track}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
>
{!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
</button>
{/* 다음 곡 스킵 버튼 */}
<button
onClick={handleSkip}
disabled={!botPlayer || !isPlaying || !track}
className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
>
<SkipForward size={20} />
</button>
{playButton(30, "h-12 w-12")}
{nextButton("h-10 w-10")}
<span className="ml-2 hidden whitespace-nowrap text-xs tabular-nums text-white/70 lg:inline">
{formatTime(position)} / {formatTime(duration)}
</span>
</div>
{/* 2. 현재 곡(누르면 재생 화면) */}
<button
type="button"
onClick={onTogglePlayer}
className="flex min-w-0 flex-1 items-center justify-center gap-4 text-left"
aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
>
<Thumb src={track?.info?.artworkUrl} className="h-10 w-10" />
<span className="min-w-0 max-w-[420px] flex-1">
<span className="block truncate text-base text-white">{title}</span>
{artist && <span className="block truncate text-base text-white/70">{artist}</span>}
</span>
</button>
{/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
<span className="w-10 text-right">{formatTime(position)}</span>
{/* 3. 볼륨 + 재생 화면 열기 */}
<div className="flex flex-shrink-0 items-center justify-end gap-1 lg:w-[292px]">
{/* 볼륨: 유튜브 뮤직처럼 마우스를 올리면 슬라이더가 옆으로 펼쳐진다 */}
<div className="group flex items-center">
<div className="relative flex h-10 w-0 items-center overflow-hidden transition-[width] duration-200 group-focus-within:w-24 group-hover:w-24">
<input
type="range"
min={0}
max={100}
value={volume}
disabled={!canControl}
aria-label="볼륨"
aria-valuetext={`${volume}%`}
onChange={(e) => dragVolume(Number(e.target.value))}
onMouseDown={beginVolumeDrag}
onTouchStart={beginVolumeDrag}
onKeyDown={(e) => { if (!e.repeat) beginVolumeDrag(); }}
onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
onKeyUp={(e) => commitVolume(Number(e.currentTarget.value))}
className="absolute inset-x-2 z-20 h-full cursor-pointer opacity-0"
/>
<div className="pointer-events-none absolute inset-x-2 h-1 rounded-full bg-white/30">
<div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
</div>
<div className="flex-1 flex items-center group relative h-4">
<input
type="range"
min={0}
max={duration || 100}
value={position}
disabled={!botPlayer || !isPlaying || !track}
onChange={(e) => {
isDragging.current = true;
setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트
}}
onMouseUp={handleSeekEnd}
onTouchEnd={handleSeekEnd}
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
// ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
/>
{/* 시각적으로 보여주는 커스텀 바 */}
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
<div
className="pointer-events-none absolute -ml-1.5 h-3 w-3 rounded-full bg-white"
style={{ left: `calc(0.5rem + (100% - 1rem) * ${volume / 100})` }}
/>
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
style={{ width: `${progressPercent}%` }}
></div>
</div>
{isVolumeDragging && (
<span className="pointer-events-none absolute bottom-[76px] right-16 rounded bg-yt-menu px-2 py-1 text-xs text-white shadow-lg">
{volume}
</span>
)}
<button
type="button"
onClick={toggleMute}
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 text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{volume === 0 ? <VolumeX size={24} aria-hidden /> : <Volume2 size={24} aria-hidden />}
</button>
{/* 동그란 핸들바 */}
<div
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}%` }}
></div>
</div>
<button
type="button"
onClick={onShuffle}
disabled={!canShuffle}
aria-label="다음 트랙 섞기"
title="다음 트랙 섞기"
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
<Shuffle size={22} aria-hidden />
</button>
<button
type="button"
onClick={onTogglePlayer}
aria-label={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
title={isPlayerOpen ? "재생 화면 닫기" : "재생 화면 열기"}
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10"
>
{isPlayerOpen ? <ChevronDown size={24} aria-hidden /> : <ChevronUp size={24} aria-hidden />}
</button>
<span className="w-10">{formatTime(duration)}</span>
</div>
</div>
{/* ===== 좁은 화면: 미니 플레이어 ===== */}
<div className="md:hidden">
<div className="h-0.5 bg-white/20" aria-hidden>
<div className="h-full bg-yt-red" style={{ width: `${percent}%` }} />
</div>
<div className="flex h-16 items-center gap-2 pl-2 pr-1">
<button
type="button"
onClick={onTogglePlayer}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
aria-label="재생 화면 열기"
{/* 3. 우측 볼륨 영역 */}
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
<div className="w-24 h-4 flex items-center group relative">
<input
type="range"
min={0}
max={100}
value={volume}
disabled={!botPlayer || !isPlaying || !track}
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
/>
{/* 커스텀 볼륨 바 디자인 */}
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
<div
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
style={{ width: `${volume}%` }}
></div>
</div>
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
<div
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}%` }}
>
<Thumb src={track?.info?.artworkUrl} className="h-11 w-11" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-white">{title}</span>
{artist && <span className="block truncate text-sm text-white/70">{artist}</span>}
</span>
</button>
{playButton(26, "h-12 w-12")}
{nextButton("h-12 w-12")}
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
{isVolumeDragging && (
<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}
</div>
)}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,183 +0,0 @@
"use client";
import { useEffect } from "react";
import { ChevronDown, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
import { Thumb } from "@/components/ui";
import { SeekBar } from "@/components/player/PlayerBar";
import QueueList from "@/components/player/QueueList";
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
interface PlayerPageProps {
player: PlayerState;
queue: QueueState;
onClose: () => void;
}
/**
* 유튜브 뮤직 재생 화면(재생바의 ⌃ 로 펼침).
* 넓은 화면: 왼쪽 큰 앨범아트, 오른쪽 "다음 트랙" 탭 — 조작은 아래 재생바가 그대로 맡는다.
* 좁은 화면: 전체 화면 플레이어(앨범아트·진행바·조작·볼륨) + 아래에 다음 트랙.
*/
export default function PlayerPage({ player, queue, onClose }: PlayerPageProps) {
const {
track, isPaused, volume, position, duration, canControl,
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
} = player;
const art = track?.info?.artworkUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
role="dialog"
aria-label="재생 화면"
className="fixed inset-0 z-[70] overflow-hidden bg-yt-bg md:absolute md:z-30"
>
{/* 배경: 앨범아트를 크게 흐려 깔고 어둡게 덮는다(유튜브 뮤직의 색감 배경 대용) */}
{art && (
// eslint-disable-next-line @next/next/no-img-element
<img src={art} alt="" aria-hidden className="absolute inset-0 h-full w-full scale-125 object-cover opacity-40 blur-3xl" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/70 to-yt-bg" aria-hidden />
{/* ===== 넓은 화면 ===== */}
<div className="relative hidden h-full gap-10 px-8 py-8 md:flex lg:gap-16 lg:px-16">
<div className="flex min-w-0 flex-1 items-center justify-center">
<Thumb
src={art}
rounded="rounded-md"
iconSize={96}
className="aspect-square w-full max-w-[min(100%,calc(100dvh-240px))] shadow-[0_20px_60px_rgba(0,0,0,0.6)]"
/>
</div>
<div className="flex w-[min(42%,480px)] flex-shrink-0 flex-col">
<div className="flex border-b border-white/20" role="tablist">
<span
role="tab"
aria-selected
className="flex-1 border-b-2 border-white pb-3 text-center text-sm font-medium uppercase tracking-wide text-white"
>
다음 트랙
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-2">
<QueueList player={player} queue={queue} variant="panel" />
</div>
</div>
</div>
{/* ===== 좁은 화면: 전체 화면 플레이어 ===== */}
<div className="relative flex h-full flex-col overflow-y-auto md:hidden">
<div className="flex h-14 flex-shrink-0 items-center px-2">
<button
type="button"
onClick={onClose}
aria-label="재생 화면 닫기"
className="flex h-11 w-11 items-center justify-center rounded-full text-white hover:bg-white/10"
>
<ChevronDown size={28} aria-hidden />
</button>
<span className="min-w-0 flex-1 truncate pr-11 text-center text-sm font-medium text-white/70">
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "지금 재생 중"}
</span>
</div>
<div className="px-6">
<Thumb
src={art}
rounded="rounded-lg"
iconSize={72}
className="mx-auto aspect-square w-full max-w-[min(100%,48dvh)] shadow-2xl"
/>
<div className="mt-6">
<p className="truncate text-xl font-bold text-white">{track?.info?.title || "재생 중인 곡 없음"}</p>
<p className="truncate text-base text-white/70">{cleanArtist(track?.info?.author)}</p>
</div>
<SeekBar player={player} thick className="mt-4" />
<div className="flex justify-between text-xs tabular-nums text-white/70">
<span>{formatTime(position)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="mt-2 flex items-center justify-center gap-6">
<button
type="button"
onClick={restart}
disabled={!canControl}
aria-label="처음부터 다시 재생"
className="flex h-12 w-12 items-center justify-center text-white disabled:text-white/30"
>
<SkipBack size={30} fill="currentColor" aria-hidden />
</button>
<button
type="button"
onClick={togglePause}
disabled={!canControl}
aria-label={isPaused ? "재생" : "일시정지"}
className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-white text-black transition active:scale-95 disabled:bg-white/30"
>
{isPaused ? <Play size={32} fill="currentColor" className="ml-1" aria-hidden /> : <Pause size={32} fill="currentColor" aria-hidden />}
</button>
<button
type="button"
onClick={skip}
disabled={!canControl}
aria-label="다음 곡"
className="flex h-12 w-12 items-center justify-center text-white disabled:text-white/30"
>
<SkipForward size={30} fill="currentColor" aria-hidden />
</button>
</div>
{/* 봇 볼륨(음성 채널 전체에 적용된다) */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={toggleMute}
disabled={!canControl}
aria-label={volume === 0 ? "음소거 해제" : "음소거"}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center text-white/70 disabled:opacity-40"
>
{volume === 0 ? <VolumeX size={22} aria-hidden /> : <Volume2 size={22} aria-hidden />}
</button>
<div className="relative flex h-10 flex-1 items-center">
<input
type="range"
min={0}
max={100}
value={volume}
disabled={!canControl}
aria-label="볼륨"
aria-valuetext={`${volume}%`}
onChange={(e) => dragVolume(Number(e.target.value))}
onMouseDown={beginVolumeDrag}
onTouchStart={beginVolumeDrag}
onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
className="absolute inset-0 z-20 h-full w-full cursor-pointer opacity-0"
/>
<div className="pointer-events-none absolute inset-x-0 h-1 rounded-full bg-white/30">
<div className="h-full rounded-full bg-white" style={{ width: `${volume}%` }} />
</div>
</div>
<span className="w-8 text-right text-xs tabular-nums text-white/70">{volume}</span>
</div>
</div>
<div className="mt-6 rounded-t-2xl bg-yt-raised/80 px-3 pt-3">
<p className="mb-2 border-b border-white/20 pb-3 text-center text-sm font-medium uppercase tracking-wide text-white">
다음 트랙
</p>
<QueueList player={player} queue={queue} variant="panel" />
</div>
</div>
</div>
);
}

View File

@@ -1,266 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp, GripVertical, ListX, Play, Shuffle, Trash2 } from "lucide-react";
import { Equalizer, Thumb } from "@/components/ui";
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
import type { QueueState } from "@/hooks/useQueue";
interface QueueListProps {
player: PlayerState;
queue: QueueState;
/** page = 서버 페이지의 트랙 목록, panel = 재생 화면의 "다음 트랙" 탭 */
variant: "page" | "panel";
}
/**
* 유튜브 뮤직 "다음 트랙" 목록.
* 맨 위에 지금 재생 중인 곡(이퀄라이저 표시), 그 아래 대기열.
* - 곡 누르기: 그 곡부터 재생(앞 곡들은 대기열에서 빠진다)
* - 마우스: 왼쪽 손잡이로 드래그해 순서 변경, hover 시 삭제 버튼
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
*/
export default function QueueList({ player, queue, variant }: QueueListProps) {
const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player;
// items 는 대기열 앞부분(최대 200곡)만, total·totalMs 는 대기열 전체 기준
const { queue: items, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue;
// "모두 지우기"는 되돌릴 수 없어서 한 번 더 눌러야 실행한다(3초 안에).
const [confirmClear, setConfirmClear] = useState(false);
useEffect(() => {
if (!confirmClear) return;
const t = setTimeout(() => setConfirmClear(false), 3000);
return () => clearTimeout(t);
}, [confirmClear]);
const totalMin = Math.round(totalMs / 60000);
const hiddenCount = Math.max(total - items.length, 0);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
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) return;
await moveTo(fromIndex, toIndex);
};
const isPage = variant === "page";
const thumbSize = "h-10 w-10";
return (
<div className={isPage ? "" : "pb-6"}>
{/* 머리말: 곡 수·총 길이 + 섞기 / 모두 지우기 */}
<div className={`mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 ${isPage ? "border-b border-yt-line pb-3" : "px-2"}`}>
<div className="min-w-0">
{isPage && <h2 className="text-2xl font-bold">재생 목록</h2>}
<p className="text-sm text-white/60">
{total > 0
? `다음 트랙 ${total.toLocaleString()}곡${totalMin > 0 ? ` · 약 ${totalMin >= 60 ? `${Math.floor(totalMin / 60).toLocaleString()}시간 ${totalMin % 60}분` : `${totalMin}분`}` : ""}`
: "다음 트랙 없음"}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={shuffle}
disabled={total < 2}
title="다음 트랙 섞기"
className="flex h-8 items-center gap-1.5 rounded-full bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover disabled:cursor-not-allowed disabled:opacity-40"
>
<Shuffle size={16} aria-hidden />
섞기
</button>
<button
type="button"
onClick={() => {
if (confirmClear) {
setConfirmClear(false);
clear();
} else setConfirmClear(true);
}}
disabled={total === 0}
title="다음 트랙 모두 지우기(지금 곡은 계속 재생)"
className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40
${confirmClear ? "bg-yt-red text-white" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
>
<ListX size={16} aria-hidden />
{confirmClear ? "한 번 더 누르면 지워요" : "모두 지우기"}
</button>
</div>
</div>
{/* 자동재생(유튜브 뮤직 "다음 트랙" 위의 스위치와 같은 자리) */}
<div className="mb-2 flex items-center justify-between gap-4 rounded-md px-2 py-2">
<span className="min-w-0">
<span className="block text-sm font-medium text-white">자동재생</span>
<span className="block text-xs text-white/60">대기열이 끝나면 비슷한 곡을 이어서 틀어요</span>
</span>
<button
type="button"
role="switch"
aria-checked={autoplay}
aria-label="자동재생"
onClick={toggleAutoplay}
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${autoplay ? "bg-[#3ea6ff]" : "bg-white/30"}`}
>
<span
className={`absolute left-0 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${autoplay ? "translate-x-[22px]" : "translate-x-0.5"}`}
/>
</button>
</div>
{/* 지금 재생 중 */}
{nowPlaying && (
<div className="flex items-center gap-4 rounded-md bg-white/10 px-2 py-2">
<span className="relative">
<Thumb src={nowPlaying.info?.artworkUrl} className={thumbSize} />
<span className="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
<Equalizer paused={isPaused} />
</span>
</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-sm text-white/70">
{cleanArtist(nowPlaying.info?.author) || "알 수 없는 아티스트"}
</span>
</span>
<span className="flex-shrink-0 pr-2 text-sm tabular-nums text-white/70">
{formatTime(nowPlaying.info?.length)}
</span>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p className="text-base text-white">대기 중인 노래가 없습니다.</p>
<p className="text-sm text-white/50">검색해서 나온 곡을 누르면 여기에 쌓입니다.</p>
</div>
) : (
<>
{nowPlaying && (
<p className="px-2 pb-1 pt-4 text-xs font-medium text-white/50">다음 트랙</p>
)}
<ul>
{items.map((item, index) => {
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
const title = item.info?.title || "제목 없음";
return (
<li
key={`${index}-${(item.encoded as string) || title}`}
draggable
onDragStart={() => {
dragItem.current = index;
setTimeout(() => setDraggingIndex(index), 0);
}}
onDragEnter={() => {
dragOverItem.current = index;
setDragOverIndex(index);
}}
onDragEnd={handleSort}
onDragOver={(e) => e.preventDefault()}
className={`group relative flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-white/10
${draggingIndex === index ? "opacity-40" : ""}
${isDropTarget ? "shadow-[inset_0_2px_0_0_#fff]" : ""}`}
>
{/* 드래그 손잡이(마우스 전용) */}
<span
className="flex w-5 flex-shrink-0 cursor-grab justify-center text-white/50 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing pointer-coarse:hidden"
aria-hidden
>
<GripVertical size={16} />
</span>
<button
type="button"
onClick={() => playFrom(index)}
className="flex min-w-0 flex-1 items-center gap-4 rounded py-1 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={`${title} 부터 재생`}
title="이 곡부터 재생"
>
<span className="relative">
<Thumb src={item.info?.artworkUrl} className={thumbSize} />
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<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">{title}</span>
<span className="block truncate text-sm text-white/70">
{cleanArtist(item.info?.author) || "알 수 없는 아티스트"}
</span>
</span>
{/* 길이: 마우스 hover 때는 삭제 버튼에 자리를 내준다 */}
<span className="flex-shrink-0 text-sm tabular-nums text-white/70 group-hover:hidden pointer-coarse:hidden">
{formatTime(item.info?.length)}
</span>
</button>
<span className="hidden flex-shrink-0 items-center group-focus-within:flex group-hover:flex pointer-coarse:flex">
<button
type="button"
onClick={() => moveBy(index, -1)}
disabled={index === 0}
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
aria-label={`${title} 위로 옮기기`}
>
<ChevronUp size={18} aria-hidden />
</button>
<button
type="button"
onClick={() => moveBy(index, 1)}
disabled={index === items.length - 1}
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
aria-label={`${title} 아래로 옮기기`}
>
<ChevronDown size={18} aria-hidden />
</button>
<button
type="button"
onClick={() => remove(index)}
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={`${title} 대기열에서 삭제`}
title="대기열에서 삭제"
>
<Trash2 size={18} aria-hidden />
</button>
</span>
</li>
);
})}
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
{draggingIndex !== null && (
<li
onDragEnter={() => {
dragOverItem.current = items.length;
setDragOverIndex(items.length);
}}
onDragOver={(e) => e.preventDefault()}
className="flex h-10 items-start"
>
{dragOverIndex === items.length && <span className="h-0.5 w-full bg-white" />}
</li>
)}
</ul>
{hiddenCount > 0 && (
<p className="px-2 pt-3 text-center text-xs text-white/50">
외 {hiddenCount.toLocaleString()}곡 · 앞의 곡이 재생되면 차례대로 보여요
</p>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music } from "lucide-react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
interface QueueSidebarProps {
selectedServer: DiscordServer | null;
}
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const { data: session, status } = useSession();
// 👇 [추가] 토스트 사용 준비 완료!
const { showToast } = useToast();
const [queue, setQueue] = useState<Track[]>([]);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
setQueue(data.queue as Track[]);
} else {
setQueue([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
setQueue([]);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
eventSource.close();
};
}, [selectedServer, status, fetchQueue]);
// 🌟 [수정됨] 드래그 앤 드롭 정렬 함수
const handleSort = async () => {
const fromIndex = dragItem.current;
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);
// 4. 서버(API)에 변경된 배열 전송
try {
const res = await fetch('/api/queue/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
newQueue: newQueue
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue(); // 실패 시 원래 큐로 복구
}
} catch (error) {
console.error("순서 동기화 실패:", error);
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
fetchQueue();
}
};
const handleDelete = async (indexToRemove: number) => {
if (!selectedServer) return;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
try {
const res = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue();
}
} catch (error) {
console.error("삭제 실패:", error);
showToast("노래를 삭제하지 못했습니다.", "error");
fetchQueue();
}
};
return (
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
{queue.length === 0 ? (
<div className="flex flex-col items-center justify-center text-neutral-500 h-full gap-2">
<Music size={32} className="opacity-50" />
<p className="text-sm">대기열에 노래가 없습니다.</p>
</div>
) : (
<div className="flex flex-col overflow-y-auto pr-2 pb-10">
{queue.map((item, index) => {
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
return (
<div
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
draggable
onDragStart={() => {
dragItem.current = index;
// 🌟 마우스 잔상(고스트)이 까맣게 캡처되는 것을 막기 위한 0초 딜레이 트릭
setTimeout(() => setDraggingIndex(index), 0);
}}
onDragEnter={() => {
dragOverItem.current = index;
setDragOverIndex(index);
}}
onDragEnd={handleSort}
onDragOver={(e) => e.preventDefault()}
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-30 scale-95 shadow-inner' : 'opacity-100'}
${isDropTarget ? 'mt-10' : 'mt-1'}
`}
>
{/* 초록색 드롭 인디케이터 선 */}
{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} />
</div>
<div className="w-10 h-10 bg-neutral-700 rounded flex-shrink-0 overflow-hidden shadow-md">
{item.info?.artworkUrl ? (
<img src={item.info.artworkUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col overflow-hidden flex-1 pointer-events-none">
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
{item.info?.title || "제목 없음"}
</span>
<span className="text-xs text-neutral-400 truncate">
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(index);
}}
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-500 transition-all p-1"
title="대기열에서 삭제"
>
<Trash2 size={16} />
</button>
</div>
);
})}
{/* 🌟 [신규 추가] 맨 아래로 드래그하기 위한 전용 투명 드롭존 */}
{draggingIndex !== null && (
<div
onDragEnter={() => {
dragOverItem.current = queue.length;
setDragOverIndex(queue.length);
}}
onDragOver={(e) => e.preventDefault()}
className={`relative h-12 flex items-center justify-center transition-all ${dragOverIndex === queue.length ? 'mt-4' : 'mt-0'
}`}
>
{dragOverIndex === queue.length && (
<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" />
)}
</div>
)}
</div>
)}
</aside>
);
}

View File

@@ -1,100 +0,0 @@
"use client";
import { Music, Server } from "lucide-react";
import type { DiscordServer } from "@/types/music";
import { serverIconUrl } from "@/hooks/useServers";
/** 유튜브 뮤직 로고 형태(빨간 원 + 흰 링 + 재생 삼각형) */
export function Logo({ className = "" }: { className?: string }) {
return (
<span className={`flex items-center gap-1 ${className}`}>
<svg viewBox="0 0 24 24" className="h-6 w-6 flex-shrink-0" aria-hidden>
<circle cx="12" cy="12" r="12" fill="#ff0000" />
<circle cx="12" cy="12" r="6.6" fill="none" stroke="#fff" strokeWidth="1.1" />
<path d="M10 9.2v5.6l4.9-2.8z" fill="#fff" />
</svg>
<span className="text-[20px] font-bold tracking-[-0.04em] text-white">MusicBot</span>
</span>
);
}
/** 유튜브 기본 썸네일(hq/mq/sd/0.jpg)은 4:3 안에 16:9 영상이 위아래 검은 띠와 함께 들어 있다. */
const LETTERBOXED_YT = /i\.ytimg\.com\/vi\/[^/]+\/(hqdefault|mqdefault|sddefault|0)\.jpg/;
/** 앨범아트/썸네일(이미지가 없으면 음표 아이콘) */
export function Thumb({
src,
className = "",
rounded = "rounded-sm",
iconSize = 18,
wide = false,
}: {
src?: string | null;
className?: string;
rounded?: string;
iconSize?: number;
/** 16:9 상자(동영상 행)면 true. 정사각형 상자에서만 검은 띠를 잘라낸다. */
wide?: boolean;
}) {
// 정사각형 상자에 4:3 유튜브 썸네일을 넣으면 검은 띠가 보이므로 4/3 배 확대해 띠를 잘라낸다.
const cropBars = !wide && !!src && LETTERBOXED_YT.test(src);
return (
<span className={`relative flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className={`h-full w-full object-cover ${cropBars ? "scale-[1.34]" : ""}`} />
) : (
<Music size={iconSize} className="text-white/40" aria-hidden />
)}
</span>
);
}
/** 디스코드 서버 아이콘(원형, 없으면 서버 아이콘) */
export function ServerAvatar({
server,
className = "",
iconSize = 20,
size = 128,
rounded = "rounded-full",
}: {
server: DiscordServer;
className?: string;
iconSize?: number;
size?: number;
rounded?: string;
}) {
const url = serverIconUrl(server, size);
return (
<span className={`flex flex-shrink-0 items-center justify-center overflow-hidden bg-yt-raised ${rounded} ${className}`}>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="" className="h-full w-full object-cover" />
) : (
<Server size={iconSize} className="text-white/50" aria-hidden />
)}
</span>
);
}
/** 유튜브 뮤직 스타일 원형 로딩 표시 */
export function Spinner({ size = 32, label = "불러오는 중" }: { size?: number; label?: string }) {
return (
<span
role="status"
aria-label={label}
className="inline-block animate-spin rounded-full border-[3px] border-white/15 border-t-white"
style={{ width: size, height: size }}
/>
);
}
/** 재생 중 이퀄라이저 표시 */
export function Equalizer({ paused = false, className = "" }: { paused?: boolean; className?: string }) {
return (
<span className={`yt-eq flex items-end gap-[2px] ${paused ? "paused" : ""} ${className}`} aria-hidden>
<span />
<span />
<span />
</span>
);
}

View File

@@ -1,376 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
/**
* 봇의 재생 상태(현재 곡·진행·볼륨)와 조작 액션.
* 재생바와 펼친 재생 화면이 같은 상태를 써야 해서 컴포넌트 밖으로 뺐다(폴링/SSE 는 한 번만).
*/
export function usePlayer(selectedServer: DiscordServer | null) {
const { data: session } = useSession();
const { showToast } = useToast();
// 재생 상태
const [track, setTrack] = useState<Track | null>(null);
const [botPlayer, setBotPlayer] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isPaused, setIsPaused] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(50);
// 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) · 봇이 들어가 있는 음성채널 이름
const [autoplay, setAutoplay] = useState<boolean>(false);
const [voiceChannelName, setVoiceChannelName] = useState<string | null>(null);
// 시간 및 진행도
const [position, setPosition] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const isDragging = useRef<boolean>(false); // 진행바를 드래그 중인지
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
const mutedVolumeRef = useRef<number>(50); // 음소거 해제 시 복원할 볼륨
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
// 1. 현재 재생 상태 불러오기 (player_now)
const fetchNowPlaying = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/player/now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
const data = await res.json();
// 봇이 옛 버전이면 이 두 값이 없을 수 있다 → 있을 때만 반영
if (res.ok && data.success) {
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
setVoiceChannelName(typeof data.voiceChannelName === "string" ? data.voiceChannelName : null);
}
if (res.ok && data.success && data.track) {
setBotPlayer(Boolean(data.botPlayer));
setIsPlaying(Boolean(data.isPlaying));
setIsPaused(Boolean(data.isPaused));
setTrack(data.track as Track);
setDuration(Number(data.track?.info?.length ?? 0) || 0);
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
setIsPlaying(false);
setPosition(0);
setDuration(0);
}
} catch (error) {
console.error("재생 정보 불러오기 실패:", error);
}
}, [selectedServer, session]);
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
useEffect(() => {
if (!selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
fetchNowPlaying();
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "player_update") {
fetchNowPlaying();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
const isPausedRef = useRef(isPaused);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
useEffect(() => {
if (!isPlaying) return;
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 15000);
return () => {
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
const canControl = !!botPlayer && !!isPlaying && !!track;
// ================= [ 컨트롤러 액션 ] =================
// 재생/일시정지 토글 (player_pause)
const togglePause = async () => {
if (!selectedServer || !track) return;
if (!isPlaying) return;
const userId = session?.user?.id;
if (!userId) return;
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
isPaused: nextPaused,
})
});
const data = await res.json();
if (res.ok && data.success) {
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
if (typeof data.paused === "boolean") setIsPaused(data.paused);
} else {
setIsPaused(!nextPaused); // 실패 시 롤백
}
} catch (error) {
console.error("일시정지 에러:", error);
setIsPaused(!nextPaused); // 실패 시 롤백
}
};
// 다음 곡 스킵 (player_skip)
const skip = async () => {
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
try {
await fetch('/api/player/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
setTimeout(fetchNowPlaying, 500);
} catch (error) {
console.error("스킵 에러:", error);
}
};
/** 이전 곡 버튼: 유튜브 뮤직처럼 지금 곡을 처음부터 다시 재생(봇에 이전 곡 기록이 없어서) */
const restart = () => {
if (!canControl) return;
commitSeek(0);
};
/** 자동재생 켜기/끄기 */
const toggleAutoplay = async () => {
if (!selectedServer) return;
const next = !autoplay;
setAutoplay(next); // 낙관적 반영
try {
const res = await fetch('/api/player/autoplay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
showToast(data.recommend ?? next ? "자동재생을 켰어요. 대기열이 끝나면 비슷한 곡을 이어서 틀어요." : "자동재생을 껐어요.", "success");
} else {
setAutoplay(!next);
showToast(data.message || data.error || "자동재생을 바꾸지 못했습니다.", "error");
}
} catch {
setAutoplay(!next);
showToast("서버와 통신할 수 없습니다.", "error");
}
};
/** 진행바를 끄는 중: 화면만 갱신하고 서버 동기화는 멈춘다. */
const dragSeek = (ms: number) => {
isDragging.current = true;
setPosition(ms);
};
/** 진행바에서 손을 뗐을 때(또는 키보드 조작 후) 실제로 탐색한다. */
const commitSeek = async (ms: number) => {
isDragging.current = false;
if (!selectedServer || !track) return;
const userId = session?.user?.id;
if (!userId) return;
setPosition(ms);
anchorRef.current = { pos: ms, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
seek: ms,
})
});
} catch (error) {
console.error("시간 이동 에러:", error);
}
};
/** 볼륨 값을 봇에 전송(실패하면 이전 값으로 되돌린다). */
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 beginVolumeDrag = () => {
prevVolumeRef.current = volume;
setIsVolumeDragging(true);
};
/** 드래그 중에는 화면만 바꾼다. */
const dragVolume = (v: number) => {
if (!selectedServer) return;
setVolume(v);
};
/** 손을 뗐을 때 딱 한 번 API 전송 */
const commitVolume = async (v: number) => {
setIsVolumeDragging(false);
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
setVolume(v);
await pushVolume(v, prevVolumeRef.current);
};
/** 스피커 아이콘 클릭 = 음소거/해제 (해제 시 직전 볼륨 복원) */
const toggleMute = 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);
}
};
/**
* 웹 음악 플레이어 표준 단축키(유튜브뮤직·스포티파이 공통):
* 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();
togglePause();
} else if (e.key === "m" || e.key === "M" || e.key === "ㅡ") {
e.preventDefault();
toggleMute();
} else if (e.key === "ArrowRight" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
skip();
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const delta = e.key === "ArrowRight" ? 5000 : -5000;
commitSeek(Math.min(Math.max(position + delta, 0), duration || 0));
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
return {
track,
isPlaying,
isPaused,
volume,
position,
duration,
canControl,
isVolumeDragging,
autoplay,
voiceChannelName,
togglePause,
restart,
toggleAutoplay,
skip,
dragSeek,
commitSeek,
beginVolumeDrag,
dragVolume,
commitVolume,
toggleMute,
};
}
export type PlayerState = ReturnType<typeof usePlayer>;
/** ms -> m:ss (유튜브뮤직 표기: 0:05, 3:21, 1:02:03) */
export function formatTime(ms?: number) {
if (!ms || Number.isNaN(ms) || ms < 0) return "0:00";
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = (total % 60).toString().padStart(2, "0");
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s}` : `${m}:${s}`;
}
/** 유튜브 자동 생성 채널 접미사(" - Topic") 제거 */
export function cleanArtist(author?: string) {
return author?.replace(" - Topic", "") ?? "";
}

View File

@@ -1,252 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, Track } from "@/types/music";
/**
* 대기열 상태와 조작. 서버 페이지 목록과 재생 화면의 "다음 트랙" 탭이 같은 데이터를 보여서
* 구독(SSE)을 한 번만 열도록 훅으로 뺐다.
*/
export function useQueue(selectedServer: DiscordServer | null) {
const { data: session, status } = useSession();
const { showToast } = useToast();
// 봇은 대기열 앞부분(최대 200곡)만 보내고, 전체 곡 수·총 길이는 따로 보낸다(스포티파이 재생목록은 최대 5만 곡).
const [queue, setQueue] = useState<Track[]>([]);
const [total, setTotal] = useState(0);
const [totalMs, setTotalMs] = useState(0);
const applyList = (items: Track[], count?: unknown, ms?: unknown) => {
setQueue(items);
setTotal(typeof count === "number" ? count : items.length);
setTotalMs(typeof ms === "number" ? ms : items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0));
};
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
const userId = session?.user?.id;
if (!userId) return;
try {
const res = await fetch('/api/queue/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
applyList(data.queue as Track[], data.total, data.totalMs);
} else {
applyList([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
applyList([]);
}
}, [selectedServer, session]);
useEffect(() => {
if (status === "loading" || !selectedServer) return;
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchQueue();
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data?.type === "queue_update") {
fetchQueue();
}
} catch (err) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
eventSource.close();
};
}, [selectedServer, status, fetchQueue]);
/**
* 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다).
* 봇은 화면에 있던 앞부분(windowSize곡)만 newQueue로 바꾸고 그 뒤 곡은 그대로 둔다. clearAll이면 전부 비운다.
*/
const applyReorder = async (newQueue: Track[], clearAll = false) => {
if (!selectedServer) return;
const previous = queue;
const windowSize = queue.length;
setQueue(newQueue);
if (clearAll) setTotal(0);
try {
const res = await fetch('/api/queue/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue,
windowSize,
clearAll,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
setQueue(previous);
fetchQueue();
}
} catch (error) {
console.error("순서 동기화 실패:", error);
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
setQueue(previous);
fetchQueue();
}
};
/** 드래그앤드롭 결과 반영. toIndex === queue.length 이면 맨 끝으로 옮긴다. */
const moveTo = async (fromIndex: number, toIndex: number) => {
let insertIndex = toIndex;
if (fromIndex < toIndex && toIndex !== queue.length) {
insertIndex = toIndex - 1;
}
if (toIndex === queue.length) insertIndex = queue.length - 1;
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
const newQueue = [...queue];
const draggedItem = newQueue.splice(fromIndex, 1)[0];
newQueue.splice(insertIndex, 0, draggedItem);
await applyReorder(newQueue);
};
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
const moveBy = 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 {
if (index > 0) {
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,
windowSize: queue.length,
})
});
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();
}
};
const remove = async (indexToRemove: number) => {
if (!selectedServer) return;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
try {
const res = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
if (!res.ok || !data.success) {
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue();
}
} catch (error) {
console.error("삭제 실패:", error);
showToast("노래를 삭제하지 못했습니다.", "error");
fetchQueue();
}
};
/** 다음 트랙 섞기(봇이 섞고 queue_update 로 새 순서를 알려준다) */
const shuffle = async () => {
if (!selectedServer) return;
if (total < 2) {
showToast("섞을 곡이 2곡 이상 있어야 해요.", "error");
return;
}
try {
const res = await fetch('/api/queue/shuffle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: selectedServer.id }),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showToast("다음 트랙을 섞었어요.", "success");
fetchQueue();
} else {
showToast(data.message || data.error || "대기열을 섞지 못했습니다.", "error");
}
} catch {
showToast("서버와 통신할 수 없습니다.", "error");
}
};
/** 다음 트랙 모두 지우기(지금 곡은 계속 재생) */
const clear = async () => {
if (total === 0) return;
await applyReorder([], true);
};
return { queue, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear };
}
export type QueueState = ReturnType<typeof useQueue>;

View File

@@ -1,107 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import type { DiscordServer } from "@/types/music";
const CACHE_KEY = "filtered_servers";
const LAST_SERVER_KEY = "musicbot:last-server";
/** 봇이 들어가 있는 내 서버 목록(세션 캐시를 먼저 보여주고 뒤에서 새로 받는다). */
export function useServers() {
const { status } = useSession();
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 캐시든 새로 받은 것이든 목록을 한 번이라도 손에 쥐었는지 */
const [loaded, setLoaded] = useState(false);
const load = useCallback((useCache: boolean) => {
setIsFetching(true);
setError(null);
if (useCache) {
const cached = sessionStorage.getItem(CACHE_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setLoaded(true);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem(CACHE_KEY);
}
}
}
fetch("/api/servers")
.then(async (res) => {
const data = await res.json().catch(() => null);
if (Array.isArray(data)) {
setServers(data);
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
} else {
setError((data && (data.error || data.message)) || "서버 목록을 가져오지 못했습니다.");
}
})
.catch(() => setError("서버와 통신할 수 없습니다."))
.finally(() => {
setIsFetching(false);
setLoaded(true);
});
}, []);
useEffect(() => {
if (status !== "authenticated") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
load(true);
}, [status, load]);
/** 캐시를 버리고 디스코드에서 다시 받아온다(봇을 막 초대했을 때 등). */
const refresh = useCallback(() => {
sessionStorage.removeItem(CACHE_KEY);
load(false);
}, [load]);
return { servers, isFetching, error, loaded, refresh };
}
/** 마지막으로 연 서버(서버 선택 화면에서 맨 앞에 "최근" 표시) */
export function getLastServerId(): string | null {
try {
return localStorage.getItem(LAST_SERVER_KEY);
} catch {
return null;
}
}
export function setLastServerId(id: string) {
try {
localStorage.setItem(LAST_SERVER_KEY, id);
} catch {
// 저장 불가(사생활 보호 모드 등)면 무시
}
}
/** 디스코드 서버 아이콘 URL(없으면 null) */
export function serverIconUrl(server: DiscordServer, size = 128) {
return server.icon
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png?size=${size}`
: null;
}
/** 내 권한 표기(서버 주인 / 관리자 / 매니저 / 멤버) */
export function permissionLabel(server: DiscordServer | null) {
if (!server) return "알 수 없음";
if (server.owner) return "서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "멤버";
try {
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "관리자";
if ((perms & 0x20n) === 0x20n) return "매니저";
return "멤버";
} catch {
return "멤버";
}
}

View File

@@ -1,21 +0,0 @@
import { getServerSession, type Session } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
/** 서버 컴포넌트용: 로그인 안 돼 있으면 로그인 페이지로 보낸다(돌아올 주소를 함께 넘긴다). */
export async function requirePageSession(callbackUrl: string): Promise<Session> {
const session = (await getServerSession(authOptions)) as Session | null;
if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
return session;
}
/** 로그인 여부만 확인(로그인 페이지에서 이미 로그인된 사람을 넘길 때) */
export async function getPageSession(): Promise<Session | null> {
return (await getServerSession(authOptions)) as Session | null;
}
/** 로그인 후 돌아갈 주소: 같은 사이트 경로만 허용(외부 주소로 튕기는 오픈 리다이렉트 방지) */
export function safeCallbackUrl(raw: unknown, fallback = "/servers") {
if (typeof raw !== "string" || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/login")) return fallback;
return raw;
}

View File

@@ -1,24 +0,0 @@
/** 최근 검색어(브라우저에만 저장). 상단 검색창 목록과 서버 홈의 "최근 검색" 칩이 같이 쓴다. */
const RECENT_KEY = "musicbot:recent-searches";
export const RECENT_MAX = 8;
export function readRecent(): string[] {
try {
const v = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").slice(0, RECENT_MAX) : [];
} catch {
return [];
}
}
export function writeRecent(list: string[]) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
window.dispatchEvent(new Event(RECENT_EVENT));
} catch {
// 저장 불가면 무시
}
}
/** 최근 검색어가 바뀌었음을 같은 탭의 다른 컴포넌트에 알린다 */
export const RECENT_EVENT = "musicbot:recent-changed";

View File

@@ -1,36 +0,0 @@
/**
* 검색창에 붙여넣은 값이 봇이 바로 불러올 수 있는 음악 주소인지 판단한다.
* (유튜브·유튜브 뮤직·스포티파이 — 곡/재생목록/앨범 모두 봇의 resolveQuery 가 처리)
*/
const MUSIC_HOSTS = [
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
"open.spotify.com",
];
export function isMusicUrl(value: string): boolean {
const v = value.trim();
if (!/^https?:\/\//i.test(v)) return false;
try {
return MUSIC_HOSTS.includes(new URL(v).hostname.toLowerCase());
} catch {
return false;
}
}
/** 재생목록·앨범처럼 여러 곡을 담은 주소인지(안내 문구용) */
export function isCollectionUrl(value: string): boolean {
try {
const u = new URL(value.trim());
return (
u.searchParams.has("list") ||
/\/(playlist|album|artist)\//.test(u.pathname) ||
u.pathname === "/playlist"
);
} catch {
return false;
}
}

View File

@@ -1,12 +0,0 @@
# soloist-bridge: 스포티파이 Soloist(공식 헤드리스 클라이언트) → MP3 스트림 중계 서버
# soloist 실행 파일은 이미지에 넣지 않고 /data/bin 에 내려받는다(빌드가 90일마다 만료되므로 자동 갱신).
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends pulseaudio pulseaudio-utils ffmpeg python3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY bridge.py entrypoint.sh /app/
RUN chmod +x /app/entrypoint.sh
ENV SOLOIST_DATA=/data SOLOIST_PORT=8780 PULSE_SINK=soloist
VOLUME /data
EXPOSE 8780
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,50 +0,0 @@
# soloist-bridge — 스포티파이 곡을 스포티파이 공식 클라이언트로 재생
스포티파이 곡(스포티파이 주소·재생목록·`-p` 검색·자동재생 추천곡)을 유튜브로 바꾸지 않고
스포티파이 공식 헤드리스 클라이언트 [Soloist](https://developer.spotify.com/documentation/soloist)로 재생한다.
Soloist가 안 되면(설정 없음·페어링 전·다른 서버가 사용 중·재생 오류) 봇이 바로 기존 유튜브 경로로 재생한다.
```
봇 ── GET /prepare?track=<id>&owner=<봇:길드>&duration=<ms> ──▶ soloist-bridge(.5:8780, docker, host 네트워크)
├ soloist --single-track spotify:track:<id>
│ └ PulseAudio 빈 싱크 "soloist"로 출력
├ parec(싱크 모니터) → 앞쪽 무음 제거 → ffmpeg MP3 320k
└ 첫 소리가 나오면 {"path":"/stream/<sid>.mp3"} 응답
Lavalink(.6) ── GET /stream/<sid>.mp3 ──▶ 곡이 끝날 때까지 실시간 MP3 스트림(http 소스로 재생)
```
## 동작 규칙
- **한 번에 한 곡**: 스포티파이 계정 하나는 동시에 한 곳에서만 재생된다. 다른 길드(또는 다른 봇)가 쓰는 중이면
`409 busy` → 그 길드는 유튜브로 재생한다. 같은 길드의 다음 곡 요청은 이전 곡을 끊고 시작한다.
- **실패 판정**: 15초 안에 소리가 안 나오거나 soloist가 먼저 끝나면 실패 → 유튜브.
- **곡 도중 끊김**(같은 계정을 휴대폰에서 재생 등): 청크 종료 없이 연결을 끊어 Lavalink가 오류로 받게 한다.
봇은 그 위치부터 같은 곡의 유튜브 음원으로 이어서 재생한다.
- **일시정지**: 받은 MP3를 세션에 모두 보관하므로 멈췄다 다시 재생해도 이어진다.
- **위치 이동(seek)**: 실시간 스트림이라 직접은 안 된다. 봇이 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
- **빌드 만료(90일)**: 실행 파일은 `/data/bin/soloist`에 자동으로 내려받는다. 만료 7일 전이거나
종료 코드 10(만료)이 나오면 최신 빌드로 자동 교체한다.
## 처음 설정 (사람이 해야 하는 일)
1. 스포티파이 **Premium** 계정으로 https://developer.spotify.com/dashboard 에 로그인 →
Spotify Soloist API Key 메뉴에서 약관 동의 후 키 발급.
2. .5에 키 저장: `/root/bot/soloist/soloist.env` 에 `SOLOIST_API_KEY=<키>` (권한 600).
3. 컨테이너 기동: `docker compose -f /root/bot/music_bot_v2/soloist/compose.yml up -d --build`
4. **페어링(1회)**: 키가 있고 로그인 정보가 없으면 브리지가 자동으로 페어링 대기 상태가 된다.
서버와 **같은 네트워크(LAN)** 에 있는 휴대폰/PC의 스포티파이 앱 → 기기 목록 → `music_bot` 선택.
`curl http://192.168.10.5:8780/health` 의 `"paired": true` 로 확인.
5. 봇 환경변수에 `SOLOIST_URL=http://192.168.10.5:8780` 을 넣고 봇을 다시 빌드/기동.
페어링한 계정으로 다른 기기에서 음악을 틀면 봇 재생이 끊긴다(→ 유튜브로 이어짐). 봇 전용 계정을 권장한다.
계정을 바꾸려면 컨테이너를 멈추고 `/root/bot/soloist/data/state` 를 지운 뒤 다시 띄워 4번을 반복한다.
## 상태 확인
- `GET /health` → `enabled`(키 있음), `paired`, `pairing`, `version`, `expiresInDays`, `busy`(지금 재생 중인 세션)
- `docker logs soloist-bridge` → `소리 시작`, `곡 도중 끊김`, `스트림 연결 종료` 등
## 주의
- 스포티파이 약관상 개인 이용 범위를 벗어난다(디스코드 여러 명에게 송출). 계정 제재 위험은 사용자가 감수한다.
- `/health`·`/prepare`에는 인증이 없다. LAN 밖으로 8780 포트를 열지 말 것.

View File

@@ -1,552 +0,0 @@
#!/usr/bin/env python3
"""
soloist-bridge — 스포티파이 공식 헤드리스 클라이언트(Soloist)로 곡을 재생하고, 그 소리를 MP3 스트림으로
Lavalink(http 소스)에 넘겨 주는 중계 서버.
흐름 (곡 하나마다)
봇 → GET /prepare?track=<스포티파이 곡 id>&owner=<봇:길드>&duration=<ms>
→ parec(빈 싱크 모니터) + ffmpeg(MP3 인코딩)을 먼저 띄우고 soloist --single-track 실행
→ 앞쪽 무음을 버리고 실제 소리가 나오기 시작하면 {"path": "/stream/<sid>.mp3"} 응답
(정해진 시간 안에 소리가 없거나 soloist가 먼저 끝나면 오류 응답 → 봇이 유튜브로 재생)
Lavalink → GET /stream/<sid>.mp3 (처음부터 끝까지 받은 MP3를 그대로 흘려 보냄)
→ soloist가 곡을 다 재생하고 종료하면 스트림도 끝남(→ Lavalink "finished")
→ 곡 도중에 끊기면(다른 기기에서 같은 계정 재생 등) 청크 종료 없이 연결을 끊어 Lavalink가 오류로 보게 함
계정 하나는 한 번에 한 곡만 재생할 수 있으므로 세션은 동시에 하나만 돈다. 다른 길드가 쓰는 중이면 409(busy)를 준다.
같은 owner의 새 요청은 이전 세션을 끊고 새로 시작한다(건너뛰기).
환경변수
SOLOIST_API_KEY 개발자 대시보드에서 만든 Soloist API 키(필수, 없으면 비활성 → /prepare 503)
SOLOIST_DEVICE_NAME 스포티파이 앱에 보일 기기 이름(기본 music_bot)
SOLOIST_DATA 세션·실행 파일 보관 폴더(기본 /data, 볼륨으로 유지해야 페어링이 남는다)
SOLOIST_BIN soloist 실행 파일 경로(기본 $SOLOIST_DATA/bin/soloist, 없으면 자동 다운로드)
SOLOIST_PORT HTTP 포트(기본 8780)
SOLOIST_PREPARE_TIMEOUT 첫 소리가 나올 때까지 기다리는 최대 초(기본 15)
PULSE_SINK 캡처할 PulseAudio 싱크 이름(기본 soloist)
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import threading
import time
import urllib.request
import uuid
from array import array
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
PORT = int(os.environ.get("SOLOIST_PORT", "8780"))
API_KEY = os.environ.get("SOLOIST_API_KEY", "").strip()
DEVICE_NAME = os.environ.get("SOLOIST_DEVICE_NAME", "music_bot").strip() or "music_bot"
DATA = os.environ.get("SOLOIST_DATA", "/data")
BIN = os.environ.get("SOLOIST_BIN", "").strip() or os.path.join(DATA, "bin", "soloist")
STATE_DIR = os.path.join(DATA, "state")
CACHE_DIR = os.path.join(DATA, "cache")
SINK = os.environ.get("PULSE_SINK", "soloist")
PREPARE_TIMEOUT = float(os.environ.get("SOLOIST_PREPARE_TIMEOUT", "15"))
BUILD_URL = os.environ.get(
"SOLOIST_BUILD_URL", "https://soloist-builds.spotifycdn.com/soloist_release_x86_64.tar.gz")
RATE, CHANNELS = 48000, 2
PCM_BYTES_PER_SEC = RATE * CHANNELS * 2
SILENCE_LEVEL = 8 # 이 값보다 큰 샘플이 나오면 '소리 시작'으로 본다(빈 싱크의 무음은 정확히 0)
TAIL_SEC = 0.8 # soloist 종료 후 싱크에 남은 소리를 더 받는 시간
IDLE_SEC = 10 # 스트림을 받는 쪽이 없을 때 세션을 정리하기까지의 시간
MAX_BUF = 80 * 1024 * 1024 # 세션당 MP3 보관 상한(320kbps 기준 30분 이상)
TRACK_RE = re.compile(r"^[A-Za-z0-9]{22}$")
EXIT_EXPIRED = 10 # soloist: 빌드 만료
def log(msg: str) -> None:
print(f"[soloist-bridge] {msg}", flush=True)
class Soloist:
"""soloist 실행 파일·페어링 상태 관리."""
def __init__(self) -> None:
self.lock = threading.Lock()
self.version = ""
self.expires_days: int | None = None
self.pairing = False
self.last_update = 0.0
self.last_error = ""
@property
def enabled(self) -> bool:
return bool(API_KEY)
@property
def paired(self) -> bool:
# soloist는 로그인 토큰을 <data-dir>/cache/dbrts 에 저장한다(페어링 전에는 없거나 0바이트).
p = os.path.join(STATE_DIR, "cache", "dbrts")
return os.path.exists(p) and os.path.getsize(p) > 0
def base_args(self) -> list[str]:
return [BIN, "-n", DEVICE_NAME, "-k", API_KEY, "-D", STATE_DIR, "-C", CACHE_DIR, "-i", "100", "-v"]
def read_version(self) -> None:
try:
out = subprocess.run([BIN, "--version"], capture_output=True, text=True, timeout=15)
self.version = (out.stdout or out.stderr).strip()
except Exception as e: # noqa: BLE001
self.version = ""
log(f"버전 확인 실패: {e}")
def note_output(self, line: str) -> None:
m = re.search(r"client expires in (-?\d+) days", line)
if m:
self.expires_days = int(m.group(1))
if self.expires_days <= 7:
self.update_async(f"만료 {self.expires_days}일 전")
def update(self, reason: str) -> bool:
"""최신 빌드를 받아 실행 파일을 교체한다. 실행 중인 프로세스는 이전 파일을 계속 쓴다."""
with self.lock:
if time.time() - self.last_update < 3600 and os.path.exists(BIN):
return False
self.last_update = time.time()
log(f"soloist 빌드 내려받기 ({reason})")
try:
os.makedirs(os.path.dirname(BIN), exist_ok=True)
tmp_tar = BIN + ".tar.gz"
with urllib.request.urlopen(BUILD_URL, timeout=120) as r, open(tmp_tar, "wb") as f:
shutil.copyfileobj(r, f)
with tarfile.open(tmp_tar) as tar:
member = tar.getmember("soloist")
src = tar.extractfile(member)
if src is None:
raise RuntimeError("tar 안에 soloist 파일이 없습니다")
with open(BIN + ".new", "wb") as f:
shutil.copyfileobj(src, f)
os.chmod(BIN + ".new", 0o755)
check = subprocess.run([BIN + ".new", "--version"], capture_output=True, text=True, timeout=15)
if check.returncode != 0:
raise RuntimeError(f"새 빌드 실행 실패(code {check.returncode})")
os.replace(BIN + ".new", BIN)
os.remove(tmp_tar)
self.expires_days = None
self.read_version()
log(f"soloist 갱신 완료: {self.version}")
return True
except Exception as e: # noqa: BLE001
log(f"soloist 갱신 실패: {e}")
return False
def update_async(self, reason: str) -> None:
threading.Thread(target=self.update, args=(reason,), daemon=True).start()
def start_pairing(self) -> None:
"""페어링이 안 돼 있으면 soloist --pair 를 띄워 둔다. 같은 네트워크의 스포티파이 앱 기기 목록에서
이 기기를 한 번 고르면 로그인 정보가 저장되고 종료된다."""
if not self.enabled:
return
with self.lock:
if self.pairing:
return
self.pairing = True
threading.Thread(target=self._pair_loop, daemon=True).start()
def _pair_loop(self) -> None:
try:
while not self.paired:
if sessions.running():
time.sleep(5)
continue
log(f'페어링 대기: 스포티파이 앱의 기기 목록에서 "{DEVICE_NAME}"를 고르세요')
proc = subprocess.Popen(self.base_args() + ["-p"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, errors="replace")
assert proc.stdout is not None
for line in proc.stdout:
line = strip_ansi(line.rstrip())
self.note_output(line)
if " E " in line or " W " in line or "pair" in line.lower():
log(f"[pair] {line}")
code = proc.wait()
if code == 0 and self.paired:
log("페어링 완료")
break
if code == EXIT_EXPIRED:
self.update("빌드 만료")
self.last_error = f"pair exit {code}"
time.sleep(30)
finally:
with self.lock:
self.pairing = False
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def strip_ansi(s: str) -> str:
return ANSI_RE.sub("", s)
def kill(proc: subprocess.Popen | None, wait: float = 3.0) -> None:
if proc is None or proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=wait)
except Exception: # noqa: BLE001
try:
proc.kill()
proc.wait(timeout=wait)
except Exception: # noqa: BLE001
pass
class Session:
def __init__(self, owner: str, track: str, duration_ms: int) -> None:
self.id = uuid.uuid4().hex
self.owner = owner
self.track = track
self.duration_ms = duration_ms
self.created = time.time()
self.buf = bytearray()
self.cond = threading.Condition()
self.started = threading.Event() # 실제 소리가 나오기 시작함
self.finished = False # MP3 출력이 끝남(정상/비정상 모두)
self.truncated = False # 곡 도중에 끊김 → 클라이언트에 오류로 알림
self.error = ""
self.clients = 0
self.last_client_seen = time.time()
self.fed_bytes = 0
self.solo_exit: int | None = None
self.solo_exit_at = 0.0
self.stopping = False
self.logs: deque[str] = deque(maxlen=40)
self.parec: subprocess.Popen | None = None
self.ffmpeg: subprocess.Popen | None = None
self.solo: subprocess.Popen | None = None
# ── 상태 ──
@property
def solo_running(self) -> bool:
return self.solo is not None and self.solo.poll() is None
@property
def played_ms(self) -> int:
return self.fed_bytes * 1000 // PCM_BYTES_PER_SEC
def summary(self) -> dict:
return {"id": self.id, "owner": self.owner, "track": self.track, "started": self.started.is_set(),
"finished": self.finished, "truncated": self.truncated, "error": self.error,
"playedMs": self.played_ms, "clients": self.clients, "bytes": len(self.buf)}
# ── 실행 ──
def start(self) -> None:
self.parec = subprocess.Popen(
["parec", "-d", f"{SINK}.monitor", "--format=s16le", f"--rate={RATE}", f"--channels={CHANNELS}",
"--raw", "--latency-msec=50"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
self.ffmpeg = subprocess.Popen(
["ffmpeg", "-hide_banner", "-loglevel", "error",
# 입력 분석(기본 최대 5초 분량)을 끄지 않으면 첫 MP3가 몇 초 늦게 나온다
"-probesize", "32", "-analyzeduration", "0", "-fflags", "nobuffer", "-f", "s16le", "-ar", str(RATE), "-ac", str(CHANNELS),
"-i", "pipe:0", "-c:a", "libmp3lame", "-b:a", "320k", "-write_xing", "0", "-id3v2_version", "0",
"-flush_packets", "1", "-f", "mp3", "pipe:1"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
self.solo = subprocess.Popen(
soloist.base_args() + ["-s", f"spotify:track:{self.track}"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace")
threading.Thread(target=self._read_solo, daemon=True).start()
threading.Thread(target=self._pump_pcm, daemon=True).start()
threading.Thread(target=self._pump_mp3, daemon=True).start()
def _read_solo(self) -> None:
assert self.solo is not None and self.solo.stdout is not None
for line in self.solo.stdout:
line = strip_ansi(line.rstrip())
if not line:
continue
self.logs.append(line)
soloist.note_output(line)
code = self.solo.wait()
self.solo_exit, self.solo_exit_at = code, time.time()
if code == EXIT_EXPIRED:
soloist.update_async("빌드 만료")
if not self.started.is_set():
self.fail(self.reason_from_logs(code))
def reason_from_logs(self, code: int) -> str:
text = "\n".join(self.logs)
if code == EXIT_EXPIRED:
return "build_expired"
if "requires stored credentials" in text or "No session to restore" in text:
soloist.start_pairing()
return "not_paired"
errs = [line for line in self.logs if " E " in line or " W " in line]
return f"soloist exit {code}: " + (errs[-1] if errs else (self.logs[-1] if self.logs else ""))
def _pump_pcm(self) -> None:
"""parec → (앞쪽 무음 버림) → ffmpeg. soloist가 끝나면 TAIL_SEC 뒤 ffmpeg 입력을 닫는다."""
assert self.parec and self.parec.stdout and self.ffmpeg and self.ffmpeg.stdin
fd = self.parec.stdout.fileno()
pending = b""
try:
while not self.stopping:
if self.solo_exit is not None and time.time() - self.solo_exit_at > TAIL_SEC:
break
chunk = os.read(fd, 9600) # 50ms
if not chunk:
break
if not self.started.is_set():
chunk = pending + chunk
usable = len(chunk) - (len(chunk) % 4)
pending = chunk[usable:]
samples = array("h", chunk[:usable])
if not samples or (max(samples) <= SILENCE_LEVEL and -min(samples) <= SILENCE_LEVEL):
continue
# 첫 소리가 있는 프레임부터 보낸다
idx = next(i for i, v in enumerate(samples) if v > SILENCE_LEVEL or -v > SILENCE_LEVEL)
chunk = chunk[(idx - idx % CHANNELS) * 2:usable]
self.started.set()
log(f"[{self.id[:8]}] 소리 시작 ({time.time() - self.created:.2f}초) track={self.track}")
self.ffmpeg.stdin.write(chunk)
self.fed_bytes += len(chunk)
except (BrokenPipeError, OSError, ValueError):
pass
finally:
kill(self.parec, 1)
try:
self.ffmpeg.stdin.close()
except Exception: # noqa: BLE001
pass
if self.started.is_set() and not self.stopping:
expected = self.duration_ms
if (self.solo_exit not in (0, None)) or (expected and self.played_ms < expected - 10000):
self.truncated = True
log(f"[{self.id[:8]}] 곡 도중 끊김: 재생 {self.played_ms}ms / 곡 {expected}ms, "
f"soloist exit={self.solo_exit} {self.logs[-1] if self.logs else ''}")
def _pump_mp3(self) -> None:
assert self.ffmpeg and self.ffmpeg.stdout
fd = self.ffmpeg.stdout.fileno()
while True:
data = os.read(fd, 65536)
if not data:
break
with self.cond:
self.buf += data
self.cond.notify_all()
if len(self.buf) > MAX_BUF:
log(f"[{self.id[:8]}] 버퍼 상한 초과, 세션 종료")
self.stop(truncate=True)
break
self.ffmpeg.wait()
with self.cond:
self.finished = True
self.cond.notify_all()
kill(self.solo)
def fail(self, reason: str) -> None:
if not self.error:
self.error = reason
self.stop(truncate=True)
self.started.set() # prepare 대기를 깨운다(error로 판별)
def stop(self, truncate: bool = False) -> None:
if truncate:
self.truncated = True
self.stopping = True
kill(self.solo)
kill(self.parec, 1)
if self.ffmpeg and self.ffmpeg.stdin:
try:
self.ffmpeg.stdin.close()
except Exception: # noqa: BLE001
pass
with self.cond:
self.cond.notify_all()
class Sessions:
def __init__(self) -> None:
self.lock = threading.Lock()
self.items: dict[str, Session] = {}
def running(self) -> Session | None:
with self.lock:
return next((s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)), None)
def get(self, sid: str) -> Session | None:
with self.lock:
return self.items.get(sid)
def create(self, owner: str, track: str, duration_ms: int) -> tuple[Session | None, str]:
with self.lock:
active = [s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)]
for s in active:
in_use = s.clients > 0 or time.time() - s.last_client_seen < IDLE_SEC
if s.owner != owner and in_use:
return None, "busy"
for s in active:
if s.started.is_set():
s.stop(truncate=True)
else:
s.fail("preempted") # 아직 준비 중인 요청은 바로 실패로 돌려준다
session = Session(owner, track, duration_ms)
self.items[session.id] = session
# 이전 soloist가 데이터 폴더 잠금(.lock)을 놓을 때까지 기다린다
for s in active:
if s.solo:
try:
s.solo.wait(timeout=5)
except Exception: # noqa: BLE001
pass
session.start()
return session, ""
def reap(self) -> None:
while True:
time.sleep(1)
now = time.time()
with self.lock:
items = list(self.items.values())
for s in items:
idle = s.clients == 0 and now - s.last_client_seen > IDLE_SEC
if idle and not s.finished and s.started.is_set():
log(f"[{s.id[:8]}] 받는 쪽이 없어 세션 종료")
s.stop(truncate=True)
if (s.finished or s.error) and idle:
with self.lock:
self.items.pop(s.id, None)
soloist = Soloist()
sessions = Sessions()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # noqa: N802
pass
def send_json(self, code: int, obj: dict) -> None:
body = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
if u.path == "/health":
run = sessions.running()
return self.send_json(200, {
"enabled": soloist.enabled, "paired": soloist.paired, "pairing": soloist.pairing,
"deviceName": DEVICE_NAME, "version": soloist.version, "expiresInDays": soloist.expires_days,
"busy": run.summary() if run else None})
if u.path == "/prepare":
return self.prepare(q)
m = re.fullmatch(r"/stream/([0-9a-f]{32})\.mp3", u.path)
if m:
return self.stream(m.group(1))
self.send_json(404, {"error": "not_found"})
def prepare(self, q: dict) -> None:
track = q.get("track", "")
owner = q.get("owner", "")
try:
duration = int(q.get("duration", "0") or 0)
except ValueError:
duration = 0
if not TRACK_RE.match(track) or not owner:
return self.send_json(400, {"error": "bad_request"})
if not soloist.enabled:
return self.send_json(503, {"error": "disabled"})
if not soloist.paired:
soloist.start_pairing()
return self.send_json(503, {"error": "not_paired"})
if soloist.pairing:
return self.send_json(503, {"error": "pairing"})
t0 = time.time()
session, err = sessions.create(owner, track, duration)
if not session:
return self.send_json(409, {"error": err})
session.started.wait(PREPARE_TIMEOUT)
if not session.started.is_set():
session.fail("no_audio_timeout")
if session.error:
log(f"[{session.id[:8]}] 준비 실패 track={track}: {session.error}")
return self.send_json(502, {"error": session.error})
session.last_client_seen = time.time()
self.send_json(200, {"path": f"/stream/{session.id}.mp3", "id": session.id,
"readyMs": int((time.time() - t0) * 1000)})
def stream(self, sid: str) -> None:
s = sessions.get(sid)
if not s or s.error:
return self.send_json(404, {"error": "no_session"})
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.close_connection = True
with s.cond:
s.clients += 1
s.last_client_seen = time.time()
pos = 0
clean = False
t_open = time.time()
try:
while True:
with s.cond:
while len(s.buf) <= pos and not s.finished and not s.stopping:
s.cond.wait(1.0)
data = bytes(s.buf[pos:pos + 65536])
done = (s.finished or s.stopping) and pos + len(data) >= len(s.buf)
if data:
self.wfile.write(b"%x\r\n" % len(data) + data + b"\r\n")
pos += len(data)
if done:
if not s.truncated:
self.wfile.write(b"0\r\n\r\n")
clean = True
break
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
with s.cond:
s.clients -= 1
s.last_client_seen = time.time()
log(f"[{s.id[:8]}] 스트림 연결 종료: {pos}바이트, {time.time() - t_open:.1f}초, 정상종료={clean}")
if not clean:
# 청크 종료 없이 끊어 받는 쪽이 '비정상 종료'로 알게 한다
try:
self.connection.shutdown(2)
except OSError:
pass
def main() -> None:
if not soloist.enabled:
log("SOLOIST_API_KEY 없음 → 비활성 상태로 시작(/prepare는 503, 봇은 유튜브로 재생)")
else:
if not os.path.exists(BIN):
soloist.update("실행 파일 없음")
soloist.read_version()
log(f"soloist: {soloist.version or '(없음)'} / 기기 이름: {DEVICE_NAME} / 페어링: {soloist.paired}")
os.makedirs(STATE_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
if not soloist.paired:
soloist.start_pairing()
threading.Thread(target=sessions.reap, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
log(f"listening on :{PORT}")
server.serve_forever()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,17 +0,0 @@
# .5 호스트 배포 예시: /root/bot 에서
# docker compose -f music_bot_v2/soloist/compose.yml up -d --build
# 스포티파이 앱의 기기 목록에 보이려면(페어링) 같은 LAN에서 mDNS가 닿아야 하므로 host 네트워크를 쓴다.
services:
soloist-bridge:
build: .
image: soloist-bridge:latest
container_name: soloist-bridge
restart: always
network_mode: host
# SOLOIST_API_KEY=... (스포티파이 개발자 대시보드에서 발급, 커밋 금지)
env_file: /root/bot/soloist/soloist.env
environment:
- SOLOIST_DEVICE_NAME=music_bot
volumes:
# 페어링 정보·soloist 실행 파일이 여기 남는다(지우면 다시 페어링해야 함)
- /root/bot/soloist/data:/data

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# PulseAudio를 빈 싱크(soloist) 하나만 둔 채로 띄우고 중계 서버를 실행한다.
# soloist는 PipeWire가 없으면 PulseAudio 기본 출력으로 재생하고, 중계 서버는 그 싱크의 모니터를 캡처한다.
set -e
export XDG_RUNTIME_DIR=/run/soloist-pulse
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
pulseaudio -n --daemonize=yes --exit-idle-time=-1 --disallow-exit --log-target=stderr \
-L "module-native-protocol-unix" \
-L "module-null-sink sink_name=${PULSE_SINK:-soloist} rate=48000 channels=2 format=s16le"
for i in $(seq 1 50); do pactl info >/dev/null 2>&1 && break; sleep 0.1; done
pactl set-default-sink "${PULSE_SINK:-soloist}"
exec python3 /app/bridge.py