화면별 사용 흐름과 버튼 동작을 전수 정리한 뒤, 스포티파이 웹 플레이어·유튜브 뮤직·애플 뮤직에서
공통으로 쓰이는 관행과 대조해 빠져 있던 것을 채웠다.
추가/변경:
- 스피커 아이콘을 장식에서 음소거 토글 버튼으로(해제 시 직전 볼륨 복원). 세 서비스 공통 동작.
- 키보드 단축키: Space 재생/일시정지, M 음소거, 좌우 5초 탐색, Ctrl/Cmd+오른쪽 다음 곡.
입력창에 포커스가 있으면 가로채지 않는다.
- 대기열 맨 위에 "지금 재생 중" 블록 추가. 재생바가 이미 폴링하는 값을 위로 올려 공유하므로
같은 API 를 두 번 호출하지 않는다.
- 대기열의 곡을 누르면 그 곡부터 재생. 봇에 점프 액션이 없어 queue/set 으로 대상 곡부터 남긴 뒤
skip 하는 방식이며, 건너뛴 곡이 빠지는 것까지 스포티파이 대기열과 동일하다.
- 검색 결과 카드에 재생시간 표기(같은 제목의 짧은 클립과 원곡 구분에 필요).
문서(page/README.md 5-1~5-3):
- 화면(ViewMode)별 진입 경로·보이는 것·할 수 있는 일 표
- 버튼/조작별 동작·호출 API·비활성 조건 전수 표
- 타 서비스 관행 대비 개선 항목과 미지원 항목(이전 곡·반복 재생: 봇 API 한계) 명시
검증: tsc 통과, lint 에러 0. 브라우저에서 음소거 토글(볼륨 0↔60 복원), Space 로 재생↔일시정지 전환,
"지금 재생 중" 표시, 대기열 곡의 "…부터 재생" 동작, 재생시간 표기를 실제 렌더링으로 확인.
실제 화면을 캡처해 확인한 문제:
- 모바일(390px)에서 3열 고정 레이아웃(사이드바 240 + 대기열 320)이 본문을 ~50px 로 눌러
"로그인이 필요합니다"가 한 글자씩 세로로 표시되는 등 사실상 사용 불가였다.
- 대기열의 삭제/드래그가 hover 에서만 노출돼, hover 가 없는 터치 기기에서는 접근 자체가 불가능.
순서 변경도 HTML5 드래그앤드롭뿐이라 모바일에서 동작하지 않았다.
- 조작 요소가 터치 기준(44px)에 한참 못 미쳤다(재생 32px, 삭제 ~24px, 진행바 높이 1px).
- 로그아웃 상태에서도 사이드바·대기열·플레이어바가 자리를 차지했고, 본문은 "로그인이 필요합니다"
라고만 적어둔 채 정작 버튼은 우상단에만 있어 동선이 끊겼다.
- 아이콘 버튼에 접근성 이름이 없고(title 만), 검색창 안내가 로그인 전에도 "서버를 먼저 선택"이었다.
변경:
- 레이아웃: 반응형으로 재구성. 좁은 화면은 단일 컬럼 + 대기열을 슬라이드 패널(FAB/플레이어바에서 토글),
넓은 화면은 기존 3열 유지. 로그인 전·서버 미선택 시에는 재생/대기열 영역을 아예 렌더링하지 않는다.
- 대기열: 조작 버튼 상시 노출, 좁은 화면에는 위/아래 이동 버튼 제공(드래그 대체), 넓은 화면은 드래그 유지.
번호·곡수 표시 추가, 실패 시 이전 순서로 롤백.
- 플레이어바: 버튼을 44~48px 로 키우고 모바일은 진행바를 상단 전체 폭으로 분리. 볼륨은 좁은 화면에서 숨김.
- 검색 결과/서버 카드: 카드 전체를 버튼으로 만들어 hover 없이도 누를 수 있게 하고 재생 아이콘 상시 노출.
- 로그인 화면에 실제 로그인 버튼 배치, 서버 미선택/결과 없음 등 빈 상태 문구 정리.
- 좌측 사이드바를 안내문 전용 죽은 공간에서 "조작 중인 서버 + 서버 전환 + 사용법"으로 교체.
- 아이콘 버튼 aria-label, range input aria-label, 장식 이미지 alt="" 정리. 프로필 이미지 없을 때 이니셜 대체.
검증: tsc 통과, lint 에러 0. 실제 렌더링을 데스크톱(1440x900)·모바일(390x844)에서 캡처해
로그인 전/서버 목록/서버 상세/검색 결과/대기열 패널 화면을 모두 확인했다.
측정으로 확인한 낭비: YoutubeMusic.getSearchUrl 적중률이 6/10(평균 548ms)이라,
빗나가는 40% 에서는 YTM 을 끝까지 기다린 뒤 다시 ytsearch(~0.5초)를 도는 순차 대기가
통째로 버려지고 있었다.
변경: resolveQuery 기본 경로에서 ytsearch 폴백 검색을 먼저 띄워두고 YTM 결과를 기다린다.
- YTM 적중 -> 폴백 결과는 버리고 기존과 동일하게 동작(Lavalink 검색 1회가 헛돌 뿐)
- YTM 빗나감 -> 이미 진행 중인 폴백 결과를 그대로 사용(추가 대기 0)
- 버려지는 폴백 promise 에 catch 를 달아 unhandledRejection 방지
- " Topic" 중복 부가 방지 조건 추가
URL 입력/`-p`(스포티파이 우선) 경로는 기존과 동일하게 동작한다.
실측 A/B(YTM 이 빗나가는 3곡, 검색+해석+리졸브 구간):
기존 1500 / 1688 / 1391ms -> 신규 1224 / 1180 / 1399ms
재생은 3곡 모두 성공, 관찰 중 멈춤 0. tsc 통과.
- resolver/ytresolver.py 를 현재 운영본(.5 /opt/ytresolver.py)과 동일하게 갱신.
(직전 커밋에서 scripts/ 로 잘못 중복 저장했던 사본은 제거)
- 루트 README / bot README:
- 아키텍처 다이어그램에 3단계 폴백(내부API 0.3초 / yt-dlp 상주 1.7초 / 쿠키 6~8초) 표기
- "왜 리졸버가 필요한가"를 SABR 기준으로 갱신, upstream feat/sabr-support 가 55초에서
403으로 끊긴다는 검증 결과와 재검증 조건을 주석으로 남김
- 성능 메모를 현재 구조(병렬화·예열·내부API)와 실측치로 갱신
첫 소리 약 7초 -> 4.9초 -> 2.6초 -> 1.6~2.3초, 남은 병목은 음성접속·유튜브뮤직 검색
기존 상주 yt-dlp(1.7~2.3초) 앞에 내부 player API 직접 호출을 두고, 실패 시 기존 경로로
자동 폴백한다. visitorData 는 리졸버가 스스로 발급/캐시(6시간)하고 만료 시 재발급하므로
봇과의 별도 배선이 필요 없다.
경로 우선순위: 내부API(0.3초) -> 상주 yt-dlp -> yt-dlp+쿠키(연령제한)
실측(운영 적용 후):
일반 유튜브 URL 리졸브 274~559ms (적용 전 1.7~2.3초)
연령제한 영상은 내부API가 LOGIN_REQUIRED 를 주어 쿠키 경로로 폴백(6~8초, 정상 재생)
재생 검증(운영 리졸버 + 운영 Lavalink, 전 케이스 성공/끊김 0):
짧은영상 18초 824ms | 일반곡 1646ms | 해외곡 2098ms | 스포티파이 2522ms
플레이리스트 2722ms | 곡전환 2230ms(전환 6.0초)
연령제한 4건(사용자 제공): 1453 / 1522 / 7653 / 8209ms 전부 재생 성공
32분 연속재생: position 5,180 -> 1,920,160ms, 멈춤 0/383
리졸버 경로 통계(테스트 1시간): 내부API 9 / 상주lib 폴백 0 / 쿠키 폴백 3(연령제한)
롤백: /opt/ytresolver.py.bak-20260918-153001 로 교체 후 systemctl restart ytresolver
운영(.5 /opt/ytresolver.py)에 적용한 내용을 형상관리한다. 기존에는 저장소 밖에만
존재해 변경 이력이 남지 않았다.
변경 핵심:
- yt-dlp zipapp 을 sys.path 에 넣어 import, YoutubeDL 인스턴스를 프로세스에 상주.
요청마다 발생하던 프로세스 기동 비용(실측 0.40s)을 제거.
- player_client=default 단독 사용(기존 default,web_safari,tv 는 첫 번째에서 이미
성공해 나머지 2개 시도 시간이 낭비됐음).
- 라이브러리 호출 실패 시 기존 subprocess 경로(익명 -> 쿠키)로 자동 폴백해 안전망 유지.
실측: 리졸브 4.2~4.7s -> 1.7~2.3s / 첫 소리 4.9s -> 2.6s
검증: 운영 리졸버+운영 Lavalink 로 90초 재생, 끊김 0/17. itag 251 동일, loadType=track.
롤백: /opt/ytresolver.py.bak-20260917-154054 로 교체 후 systemctl restart ytresolver
기존: player_play 가 channelJoin() 완료 후에야 search() 를 시작(직렬)하고,
첫 곡은 playNext 시점에 yt-dlp 리졸브(콜드 3.6~4.2s)를 그제서야 수행했다.
- LavalinkManager.search 를 resolveQuery(해석) + addResolved(큐 반영)로 분리.
resolveQuery 는 플레이어가 필요 없어 음성접속과 동시에 실행 가능.
- resolveQuery 가 해석 직후 prewarmTrack() 으로 첫 곡 오디오 리졸브를 즉시 예열.
GuildPlayer.resolvePlayable 과 동일한 target 계산식을 써 캐시가 확실히 적중하게 함.
- RedisClient player_play/player_playlist: 해석을 먼저 출발시키고 음성접속과 병렬 진행.
- 구간 계측 로그 추가(음성접속/해석대기/오디오 리졸브 대기).
검증: tsc 통과. 실측 A/B(동일 조건, 미캐시 곡):
기존 직렬 접속2.00s+리졸브3.03s = 5.03s → 신규 병렬 2.00s+0.97s = 2.98s (약 2s 단축).
jsdom 30은 require('undici') 시 webidl.util.markAsUncloneable is not a function
(node:20-alpine/undici 비호환)으로 import 단계에서 크래시 → 자식 프로세스 code 1.
jsdom 25.0.1은 undici 6를 사용해 해당 경로를 타지 않으며 node:20-alpine(musl)에서
npm ci 재현 빌드 + 실제 실행으로 poToken 생성·주입(204) 검증 완료.
또한 LavalinkManager 실패 로그가 마지막 줄(=Node.js 버전 푸터)만 남겨 원인을 가리던 문제를
전체 출력 꼬리를 남기도록 수정.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
googlevideo 재생 403(스트림 거절)은 Lavalink youtube-plugin에 유효한
poToken/visitorData가 없어서 발생. 봇이 노드 ready 시점과 2시간 주기로
poToken을 생성해 Lavalink의 POST /youtube 엔드포인트에 주입한다.
- scripts/potoken-refresh.mjs: youtubei.js+bgutils-js+jsdom로 생성 후 주입 (독립 ESM 프로세스)
- LavalinkManager: ready 이벤트 + 2h 인터벌로 자식 프로세스 실행
- deps: bgutils-js 3.2.0, jsdom, youtubei.js 추가 / Docker base node:22-alpine
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내 end 핸들러가 다음 곡을
진행시킨다. 기존 exception 핸들러의 errMsg는 5초 뒤 queue[0]을 다시 재생해, 이미
진행된 다음 곡을 재시작시키는 부작용이 있었음.
- errMsg에 retry 파라미터 추가(기본 true). exception 경로는 retry=false로 호출.
- exception이 end를 유발하므로 stopTrack은 부르지 않음(중복 end로 곡 하나 더 스킵 방지).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
노래가 2/3 지점 등에서 버퍼링/멈춘 뒤 다음 곡으로 넘어가지 않고 프리징되던 문제 해결.
- stuck 이벤트: 기존 errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수
있었음. stopTrack으로 end 이벤트를 유도해 실제 다음 곡으로 넘김.
- 재생 위치 멈춤 감시(watchdog) 추가: TrackStuckEvent가 안 뜨는 느린 버퍼링
상황에서도 position이 20초간 진행 없으면 자동 스킵. start에서 시작, clearAllTimers에서 정리.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
기존 player가 있어 channelJoin을 건너뛰는 경로에서 search()가 노드 미연결로
reject될 때 handleMessage는 이미 종료돼 .catch가 잡지 못하던 문제 수정.
- handleMessage 내부 search() await 처리
- prefix 명령어 경로의 messageRun() await 처리 (try/catch가 비동기 오류도 포착)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
노드가 CONNECTED 상태가 아닐 때 joinVoiceChannel이 throw 되어
fire-and-forget handleMessage에서 처리되지 않은 Promise 거부로 번지던 문제 수정.
- LavalinkManager.hasReadyNode() 추가
- channelJoin에서 노드 미연결 시 안내 임베드 반환 + join try/catch
- messageCreate의 handleMessage 호출에 .catch 추가
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
폴백 시 lastPlayed.identifier(Spotify ID)와 RD 믹스 첫 곡(YouTube ID)
비교가 안 맞아서, 방금 끝난 곡의 YouTube 버전이 자동재생 큐에 그대로
들어가던 버그 수정.
- youtubeMixFromSpotifyTrack: { result, seedYtId } 반환
- autoPlay: 폴백일 때 dedup 기준 ID를 seedYtId로 교체
- sprec이 EMPTY/ERROR/빈 PLAYLIST면 곡 메타데이터(artist, title)로
ytsearch 후 첫 결과의 videoId로 RD<id> 믹스를 가져와 자동재생 큐 채움
- 리뷰어 확인: ytmsearch는 현재 Lavalink에서 EMPTY를 반환해서 ytsearch 사용
- sprec / 검색 / 믹스 resolve 각 단계에 try/catch 추가 (한 단계 실패 시
다음 단계로 진행하지 않고 폴백 또는 end로 안전하게 빠짐)
- 진단 로깅: sprec loadType, 폴백 사유, 검색 매칭 videoId, 최종 후보 곡수
- closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별)
- 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을
position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원)
- 재접속 실패 시에만 기존처럼 player 정리
- 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화
- 이전 커밋에서 제거한 axios가 lock 파일에 남아있던 것 정리
- npm audit fix로 transitive 의존성 보안 패치 적용 완료
- 보안 취약점 0건 (이전: high 2, moderate 1)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- GuildPlayer: 타이머 레이스 컨디션 수정, 모든 타이머 정리 로직 통합 (clearAllTimers)
- GuildPlayer: 이벤트 핸들러에 try-catch 추가 (end, exception, stuck)
- GuildPlayer: start 이벤트에서 endTimer 정리, autoPlay tracks 길이 검증 추가
- RedisClient: player_seek, player_volume에 누락된 return ���가
- RedisClient: queue_remove 인덱스 검증 주석 명확화
- Handler: runCommand에 try-catch 추가하여 에러 시 사용자에게 응답
- Channel: getGuildById에 누락된 await 추가, getMemberById/getVoiceChannelById 안전한 에러 처리
- Command.d.ts: 잘못된 타입 ChatInputChatInputCommandInteraction → ChatInputCommandInteraction 수정
- join.ts: 채널 멘션 닫는 괄호 누락 수정
- shuffle.ts: 제네릭 타입 적용, 불필요한 5회 반복 제거
- import 경로 대소문자 수정 (Shuffle → shuffle) - Linux 호환
- YoutubeMusic/Spotify: 하드코딩된 IP를 환경변수로 분리
- console.log/error → Logger 통일 (YoutubeMusic, Button, channel)
- interactionCreate: 전체 try-catch 추가, silent catch에 로깅 추가
- Database: schema 경로 __dirname 기반으로 수정, 컬럼 화이트리스트 추가
- 사용하지 않는 코드 정리 (axios 의존성, 주석처리된 user 관련 코드)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>