- 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:<id>)을 바로 추가
(기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초)
- GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치)
- 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록
- 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체
- '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열
- queue_set: JSON으로 깨진 _resolved({}) 제거
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
page
- /login(디스코드 로그인 전용), /servers(서버 선택 전용), /servers/[id](유튜브 뮤직 플레이어)로 분리
· 서버 컴포넌트에서 로그인 검사, callbackUrl 은 같은 사이트 경로만 허용
· NextAuth 로그인/오류 화면도 /login 으로
- 서버 선택: 최근 사용 서버 맨 앞 "최근" 표시, 새로고침, 8개 초과 시 이름 검색, 오류 표시
- 플레이어: 검색어를 ?q= 로 남겨 뒤로가기 지원, 탭 제목에 지금 곡, 내 서버가 아니면 안내
- 검색창: 최근 검색어 목록, 유튜브·스포티파이 주소를 붙여넣으면 곡/재생목록 바로 불러오기
- 다음 트랙: 섞기, 모두 지우기(두 번 눌러 확인), 자동재생 스위치, 곡 수·총 길이
- 재생바: 처음부터 다시(⏮), 섞기 버튼 / 서버 홈: 음성채널 표시, 사용 안내, 최근 검색 칩
- 곡 추가 중 스피너 + 중복 추가 방지, 검색 실패 시 다시 시도, play/playlist 응답 대기 15초
- 계정 전환형 프로필 메뉴와 사이드바 서버 목록 제거(프로필은 서버 변경·로그아웃만)
bot
- Redis 액션 queue_shuffle, player_recommend 추가
- player_now 응답에 recommend(자동재생), voiceChannelName 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 상단 바: ☰ + 로고 + 반투명 검색창 + 프로필 메뉴(좁은 화면은 돋보기 → 전체 폭 검색)
- 왼쪽 메뉴: 홈/서버 목록 + 봇이 있는 내 서버 목록, 넓은 화면 펼침(240)/접힘(72), 좁은 화면 서랍
- 홈: 서버를 원형 카드로, 서버 페이지: 앨범 페이지 형태(왼쪽 아이콘·정보, 오른쪽 재생 목록)
- 검색: 필터 칩(전체/노래/동영상/Spotify) + 상위 검색결과 카드 + 구역별 목록(모두 표시)
- 하단 재생바: 위쪽 빨간 진행선, 조작·시간 / 곡 정보 / hover 볼륨·재생 화면(⌃)
- 재생 화면: 큰 앨범아트 + 다음 트랙 탭, 좁은 화면은 전체 화면 플레이어
- 재생/대기열/서버 로직을 hooks(usePlayer/useQueue/useServers)로 분리해 구독을 한 번만 연다
- 스낵바 알림, 파비콘, README 화면·버튼 문서 갱신
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 재생목록/앨범: Spotify Web API 정책 변경으로 LavaSrc가 403으로 실패 → 봇이 공개 임베드에서
곡 목록(최대 100곡)을 받아 곡마다 유튜브 원곡을 매칭. 첫 곡 즉시 재생, 나머지는 백그라운드로 추가
- 단일 곡: 유튜브뮤직 검색이 팟캐스트·분석 영상을 줘 엉뚱한 곡이 걸리던 문제 → 제목·아티스트·
재생시간(±수 초)·2차영상 감점으로 유튜브 검색 결과 중 원곡 오디오를 선택(TrackMatch)
- open.spotify.com/intl-ko/... 링크도 인식
- 유튜브뮤직 검색 결과에서 팟캐스트 에피소드·재생목록 등 비곡 항목 제외
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
검색 결과를 그리드 카드에서 리스트 행으로 바꾸고 대기열 접기/펴기를 추가한 뒤에도
문서에 이전 표현이 남아 있어 실제 동작과 어긋났다. 코드와 대조해 네 곳을 고쳤다.
- 5-1 SEARCH_RESULT: "곡 카드" -> "곡 리스트 행(번호·썸네일·제목/아티스트·재생시간)"
- 5-1 대기열 패널: 화면 크기별 기본값과 여는 방법을 명시
(넓은 화면=기본 펼침·재생바 우측 버튼으로 접기, 좁은 화면=기본 접힘·FAB/재생바 버튼으로 열기)
- 5-2 본문 버튼: "곡 카드(카드 전체가 버튼)" -> "곡 행(행 전체가 버튼)",
재생 아이콘이 넓은 화면에선 hover 시 번호 자리에, 좁은 화면에선 썸네일에 상시 노출되는 점 추가
- 5-2 재생바 목록 아이콘: 한 줄로 뭉뚱그려 "좁은 화면 전용"이라 적혀 있던 것을
좁은 화면(슬라이드 패널 열기)과 넓은 화면(우측 컬럼 접기/펴기, 펼침 시 초록 표시)으로 분리
- 5-3 재생시간 표기: "검색 결과 카드" -> "검색 결과 행 오른쪽"
문서만 변경이라 코드/배포 산출물은 그대로다.
"많이 쓰는 음악 사이트처럼" 이라는 요구에 맞춰, 형태가 관행과 달랐던 두 곳을 바꿨다.
1) 검색 결과: 그리드 카드 -> 리스트 행
그리드(커버 중심)는 앨범·재생목록을 훑을 때 쓰는 형태이고, 개별 곡 검색 결과는
스포티파이·유튜브뮤직 모두 행(번호/썸네일/제목·아티스트/재생시간) 형식이다.
행 전체가 버튼이라 터치로도 바로 누를 수 있고, 마우스 환경에서는 번호 자리에
재생 아이콘이 겹치는 스포티파이 곡 목록 방식을 따랐다. 좁은 화면은 hover 가 없어
썸네일에 재생 아이콘을 항상 얹는다.
2) 대기열 접기/펴기
재생바 우측(스포티파이와 같은 자리)에 대기열 버튼을 두어 넓은 화면에서도 접을 수 있게 했다.
화면 크기별 기본값이 달라(좁은 화면=접힘, 넓은 화면=펼침) 상태를 둘로 나눴다.
창 크기를 읽어 한 상태로 합치면 SSR/하이드레이션이 어긋나고 setState-in-effect 경고도 난다.
검증: tsc 통과, lint 에러 0. 데스크톱(1440x900)·모바일(390x844)에서 검색 결과 행 렌더링과
대기열 접기/펴기 동작을 실제 화면으로 확인. README 5-3 비교표에 두 항목 추가.
화면별 사용 흐름과 버튼 동작을 전수 정리한 뒤, 스포티파이 웹 플레이어·유튜브 뮤직·애플 뮤직에서
공통으로 쓰이는 관행과 대조해 빠져 있던 것을 채웠다.
추가/변경:
- 스피커 아이콘을 장식에서 음소거 토글 버튼으로(해제 시 직전 볼륨 복원). 세 서비스 공통 동작.
- 키보드 단축키: 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>