Merge branch 'main' into delete-redis
# Conflicts: # package.json # yarn.lock
This commit is contained in:
commit
1f91bfb9bf
|
|
@ -0,0 +1,314 @@
|
||||||
|
# Kord - YouTube 음악 재생 기능 기획안 (YouTube Music Playback)
|
||||||
|
|
||||||
|
## 체인지로그 (Changelog)
|
||||||
|
- **2026-03-30**: 최초 기획안 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
YouTube 음악 재생 기능은 사용자가 텍스트 기반 명령으로 음악을 검색하거나 YouTube 링크를 입력하면,
|
||||||
|
현재 참여 중인 음성 채팅방에 봇이 입장하여 재생 목록(플레이리스트)을 관리하고 오디오를 재생하는 기능입니다.
|
||||||
|
|
||||||
|
이 기능은 서버 유틸리티를 넘어서 실시간 상호작용 기능으로 확장되는 첫 사례이며,
|
||||||
|
음성 연결, 큐 관리, UI 컨트롤, 외부 미디어 소스 처리까지 포함하는 비교적 큰 범위의 기능입니다.
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
|
||||||
|
- 문자열 검색으로 YouTube 영상을 찾아 재생 목록에 추가할 수 있어야 함
|
||||||
|
- YouTube 링크를 입력해 직접 재생 목록에 추가할 수 있어야 함
|
||||||
|
- 현재 재생 목록을 조회할 수 있어야 함
|
||||||
|
- 인덱스 기반으로 재생 목록 항목을 삭제할 수 있어야 함
|
||||||
|
- 재생 중지 / 스킵 / 일시정지 / 재개 등 기본 컨트롤을 버튼 또는 이모지 기반 UI로 제공해야 함
|
||||||
|
- 음악 추가 요청 시, 요청자가 있는 음성 채팅방에 봇이 자동 입장하고 재생을 시작해야 함
|
||||||
|
- 마지막에 봇을 음성 채팅방에서 내보내는 기능이 있어야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 지원 범위 (MVP)
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `/music add query:<문자열>`
|
||||||
|
- `/music add url:<YouTube 링크>`
|
||||||
|
- `/music queue`
|
||||||
|
- `/music remove index:<번호>`
|
||||||
|
- `/music skip`
|
||||||
|
- `/music stop`
|
||||||
|
- `/music leave`
|
||||||
|
- 재생 컨트롤 메시지 (버튼 또는 이모지 라벨 기반)
|
||||||
|
- 음성 채널 자동 입장 및 큐 기반 연속 재생
|
||||||
|
|
||||||
|
### 제외 (초기 범위 외)
|
||||||
|
|
||||||
|
- Spotify, SoundCloud 등 타 플랫폼 연동
|
||||||
|
- 반복 재생 / 셔플 / 볼륨 조절
|
||||||
|
- 길드별 DJ 역할 분리
|
||||||
|
- 재생 이력 저장
|
||||||
|
- 노래 가사 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사용자 시나리오
|
||||||
|
|
||||||
|
### 시나리오 A: 검색어로 곡 추가
|
||||||
|
|
||||||
|
1. 사용자가 음성 채널에 입장한 상태에서 `/music add query:아이유 좋은날` 실행
|
||||||
|
2. 봇이 검색 결과 1위를 선택하거나 선택 UI를 제공
|
||||||
|
3. 재생 목록에 곡을 추가
|
||||||
|
4. 봇이 음성 채널에 자동 입장
|
||||||
|
5. 현재 재생 중이 없다면 즉시 재생 시작
|
||||||
|
|
||||||
|
### 시나리오 B: 링크로 곡 추가
|
||||||
|
|
||||||
|
1. 사용자가 `/music add url:https://www.youtube.com/watch?v=...` 실행
|
||||||
|
2. 봇이 링크 메타데이터를 파싱
|
||||||
|
3. 재생 목록에 항목 추가
|
||||||
|
4. 이미 재생 중이면 큐 뒤에 대기
|
||||||
|
|
||||||
|
### 시나리오 C: 큐 조회 및 삭제
|
||||||
|
|
||||||
|
1. 사용자가 `/music queue` 실행
|
||||||
|
2. 봇이 현재 재생곡과 대기열을 인덱스와 함께 Embed로 표시
|
||||||
|
3. 사용자가 `/music remove index:3` 실행
|
||||||
|
4. 3번 항목이 큐에서 제거됨
|
||||||
|
|
||||||
|
### 시나리오 D: 컨트롤 UI 사용
|
||||||
|
|
||||||
|
1. 재생 시작 시 봇이 "지금 재생 중" 메시지를 게시
|
||||||
|
2. 메시지에는 ⏸️, ▶️, ⏭️, ⏹️ 같은 버튼이 포함됨
|
||||||
|
3. 사용자가 버튼을 눌러 재생 상태를 제어
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 명령 구조 제안
|
||||||
|
|
||||||
|
### `/music add`
|
||||||
|
|
||||||
|
입력 방식:
|
||||||
|
|
||||||
|
- `query`: YouTube 검색 문자열
|
||||||
|
- `url`: YouTube 링크
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 두 옵션 중 하나만 입력
|
||||||
|
- 사용자는 반드시 음성 채널에 있어야 함
|
||||||
|
|
||||||
|
### `/music queue`
|
||||||
|
|
||||||
|
출력:
|
||||||
|
|
||||||
|
- 현재 재생 중 항목
|
||||||
|
- 대기열 목록
|
||||||
|
- 각 곡의 인덱스, 제목, 길이, 요청자
|
||||||
|
|
||||||
|
### `/music remove`
|
||||||
|
|
||||||
|
입력:
|
||||||
|
|
||||||
|
- `index`: 큐에서 제거할 번호
|
||||||
|
|
||||||
|
### `/music skip`
|
||||||
|
|
||||||
|
- 현재 곡을 스킵하고 다음 곡 재생
|
||||||
|
|
||||||
|
### `/music stop`
|
||||||
|
|
||||||
|
- 재생 중지
|
||||||
|
- 현재 곡 정지 및 대기열 비움 여부는 정책 선택 필요
|
||||||
|
|
||||||
|
### `/music leave`
|
||||||
|
|
||||||
|
- 봇을 음성 채널에서 퇴장시킴
|
||||||
|
- 기본 정책은 큐도 함께 정리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 재생 컨트롤 UI
|
||||||
|
|
||||||
|
### 컨트롤 메시지 구성
|
||||||
|
|
||||||
|
재생 시작 시 텍스트 채널에 Embed + 버튼 메시지 생성
|
||||||
|
|
||||||
|
권장 버튼:
|
||||||
|
|
||||||
|
- `⏸️` 일시정지
|
||||||
|
- `▶️` 재개
|
||||||
|
- `⏭️` 스킵
|
||||||
|
- `⏹️` 정지
|
||||||
|
- `📜` 큐 보기
|
||||||
|
|
||||||
|
### 정책
|
||||||
|
|
||||||
|
- 한 길드당 활성 컨트롤 메시지 1개 유지
|
||||||
|
- 곡이 바뀔 때 동일 메시지를 업데이트하거나 새 메시지를 생성할지 결정 필요
|
||||||
|
- MVP에서는 새 메시지 생성보다 기존 메시지 업데이트가 로그 노이즈를 줄이는 데 유리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 큐 및 재생 상태 설계
|
||||||
|
|
||||||
|
### 핵심 개념
|
||||||
|
|
||||||
|
- 재생 상태는 길드 단위로 분리
|
||||||
|
- 각 길드는 하나의 음성 연결과 하나의 큐를 가짐
|
||||||
|
- 큐는 메모리 기반으로 시작하고, 필요 시 DB 영속화 확장 가능
|
||||||
|
|
||||||
|
### 추천 구조
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MusicQueueItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
durationSec?: number;
|
||||||
|
requestedByUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuildMusicSession {
|
||||||
|
guildId: string;
|
||||||
|
voiceChannelId: string;
|
||||||
|
textChannelId: string;
|
||||||
|
nowPlaying: MusicQueueItem | null;
|
||||||
|
queue: MusicQueueItem[];
|
||||||
|
paused: boolean;
|
||||||
|
controlMessageId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 초기 전략
|
||||||
|
|
||||||
|
- 메모리 기반 세션 관리
|
||||||
|
- 봇 재시작 시 큐는 초기화됨
|
||||||
|
- 안정화 이후 DB 저장 여부 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 음성 입장 및 재생 흐름
|
||||||
|
|
||||||
|
### 자동 입장
|
||||||
|
|
||||||
|
- `/music add` 실행 시 사용자의 음성 채널을 확인
|
||||||
|
- 봇이 해당 채널에 없으면 자동 입장
|
||||||
|
- 이미 다른 채널에 있으면 정책 필요
|
||||||
|
|
||||||
|
### 기본 정책 제안
|
||||||
|
|
||||||
|
- 같은 길드 내에서 봇이 이미 재생 중이면 다른 채널 요청은 거부
|
||||||
|
- "현재 다른 음성 채널에서 사용 중" 메시지 제공
|
||||||
|
|
||||||
|
### 자동 퇴장
|
||||||
|
|
||||||
|
- `/music leave`로 수동 퇴장 가능
|
||||||
|
- 추가 정책 후보:
|
||||||
|
- 큐가 끝나고 1분 뒤 자동 퇴장
|
||||||
|
- 음성 채널에 봇만 남으면 자동 퇴장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. YouTube 검색 및 스트리밍 전략
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> YouTube 관련 기능은 라이브러리 안정성, 차단 이슈, 서비스 정책을 반드시 검토해야 합니다.
|
||||||
|
|
||||||
|
### 검색
|
||||||
|
|
||||||
|
가능한 접근:
|
||||||
|
|
||||||
|
- YouTube 공식 API 사용
|
||||||
|
- 서드파티 검색 라이브러리 사용
|
||||||
|
|
||||||
|
### 오디오 스트리밍
|
||||||
|
|
||||||
|
가능한 접근:
|
||||||
|
|
||||||
|
- `@discordjs/voice` 기반 음성 재생
|
||||||
|
- YouTube 스트림 추출 라이브러리 사용
|
||||||
|
|
||||||
|
### 기술 리스크
|
||||||
|
|
||||||
|
- YouTube 구조 변경 시 스트림 추출 라이브러리 고장 가능
|
||||||
|
- 지역 제한 / 연령 제한 / 라이브 영상 처리 문제
|
||||||
|
- 긴 재생 목록에서 메모리 및 연결 안정성 문제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 권한 및 운영 정책
|
||||||
|
|
||||||
|
### 봇 권한
|
||||||
|
|
||||||
|
- `Connect`
|
||||||
|
- `Speak`
|
||||||
|
- `View Channel`
|
||||||
|
- 텍스트 채널의 `Send Messages`, `Embed Links`
|
||||||
|
|
||||||
|
### 사용자 권한
|
||||||
|
|
||||||
|
- 기본적으로 일반 사용자도 곡 추가 가능
|
||||||
|
- `skip`, `stop`, `leave`, `remove`는 다음 중 하나로 제한 가능
|
||||||
|
- 관리자 전용
|
||||||
|
- 요청자 또는 관리자
|
||||||
|
- 같은 음성 채널 참여자 전원 허용
|
||||||
|
|
||||||
|
### 추천 MVP 정책
|
||||||
|
|
||||||
|
- `add`, `queue`: 같은 음성 채널 참여자 누구나 가능
|
||||||
|
- `skip`, `stop`, `remove`, `leave`: 관리자 또는 같은 음성 채널 참여자 허용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 에러 처리
|
||||||
|
|
||||||
|
필수 안내 케이스:
|
||||||
|
|
||||||
|
- 사용자가 음성 채널에 없음
|
||||||
|
- 유효하지 않은 YouTube 링크
|
||||||
|
- 검색 결과 없음
|
||||||
|
- 재생 목록 인덱스 범위 오류
|
||||||
|
- 봇 음성 권한 부족
|
||||||
|
- 스트림 로드 실패
|
||||||
|
|
||||||
|
에러 메시지는 기존 Error Guidance 체계와 연결하는 것이 좋습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 구현 단계 (Phased Implementation)
|
||||||
|
|
||||||
|
| 단계 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| **Phase 1** | `@discordjs/voice` 기반 음성 연결, 메모리 큐, `/music add`, `/music queue`, `/music skip`, `/music leave` |
|
||||||
|
| **Phase 2** | 링크 기반 추가 + 검색 기반 추가 분리, `/music remove`, `/music stop` |
|
||||||
|
| **Phase 3** | 컨트롤 메시지(⏸️ ▶️ ⏭️ ⏹️) 및 상호작용 처리 |
|
||||||
|
| **Phase 4** | 자동 퇴장, 권한 정책 세분화, 예외 처리 고도화 |
|
||||||
|
| **Phase 5** | 반복 재생, 셔플, DJ 역할, 재생 이력 등 확장 기능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 검증 계획
|
||||||
|
|
||||||
|
### 수동 테스트
|
||||||
|
|
||||||
|
1. 음성 채널 입장 후 검색어로 곡 추가
|
||||||
|
2. 링크로 곡 추가
|
||||||
|
3. 큐 조회 및 인덱스 삭제
|
||||||
|
4. 스킵 / 정지 / 퇴장 동작 확인
|
||||||
|
5. 곡 종료 후 다음 곡 자동 재생 확인
|
||||||
|
6. 권한 부족 환경에서 적절한 에러 표시 확인
|
||||||
|
|
||||||
|
### 자동 테스트
|
||||||
|
|
||||||
|
- 큐 삽입 / 삭제 / 스킵 로직 단위 테스트
|
||||||
|
- 길드별 세션 분리 테스트
|
||||||
|
- 인덱스 유효성 검사 테스트
|
||||||
|
- 컨트롤 인터랙션 핸들러 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 관련 문서
|
||||||
|
|
||||||
|
| 문서 | 링크 |
|
||||||
|
|------|------|
|
||||||
|
| 기능 로드맵 | [`Feature_Roadmap.md`](./Feature_Roadmap.md) |
|
||||||
|
| 임시 음성 채널 기획 | [`Temp_Voice_Channel_Plan.md`](./Temp_Voice_Channel_Plan.md) |
|
||||||
|
| 에러 안내 기획 | [`Error_Guidance_Plan.md`](./Error_Guidance_Plan.md) |
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Kord - YouTube 음악 재생 Phase 1 구현
|
||||||
|
|
||||||
|
## 변경 개요
|
||||||
|
|
||||||
|
`@discordjs/voice + youtubei.js` 조합을 기준으로 YouTube 음악 재생 기능의 1차 구현을 추가했다.
|
||||||
|
|
||||||
|
이번 단계에서는 길드별 메모리 큐와 음성 연결 세션을 도입하고, 검색 또는 영상 URL로 곡을 추가해 바로 재생할 수 있는 최소 워크플로우를 구현했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위
|
||||||
|
|
||||||
|
- `/music add`
|
||||||
|
- `query`로 YouTube 검색 후 첫 번째 영상 추가
|
||||||
|
- `url`로 YouTube 영상 링크 직접 추가
|
||||||
|
- `/music queue`
|
||||||
|
- 현재 재생 곡과 대기열 조회
|
||||||
|
- `/music skip`
|
||||||
|
- 현재 곡 스킵
|
||||||
|
- `/music leave`
|
||||||
|
- 봇 음성 채널 퇴장 및 큐 정리
|
||||||
|
- 재생 컨트롤 버튼
|
||||||
|
- `⏭ Skip`
|
||||||
|
- `⏹ Stop`
|
||||||
|
- `👋 Leave`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 구현
|
||||||
|
|
||||||
|
### 1. MusicService 추가
|
||||||
|
|
||||||
|
`src/services/MusicService.ts`
|
||||||
|
|
||||||
|
- 길드별 음악 세션을 메모리에서 관리
|
||||||
|
- `youtubei.js`를 lazy import로 초기화
|
||||||
|
- `getBasicInfo()`로 메타데이터 조회
|
||||||
|
- `getInfo()`의 `server_abr_streaming_url` 또는 `hls_manifest_url`을 재생 소스로 사용
|
||||||
|
- `ffmpeg-static`으로 원격 스트림을 PCM으로 디코딩
|
||||||
|
- `@discordjs/voice` 플레이어에 연결
|
||||||
|
|
||||||
|
### 2. Slash Command 추가
|
||||||
|
|
||||||
|
`src/commands/music.ts`
|
||||||
|
|
||||||
|
- `/music add`
|
||||||
|
- `/music queue`
|
||||||
|
- `/music skip`
|
||||||
|
- `/music leave`
|
||||||
|
|
||||||
|
### 3. 버튼 상호작용 연결
|
||||||
|
|
||||||
|
`src/events/interactionCreate.ts`
|
||||||
|
|
||||||
|
- 재생 메시지의 `music_*` 버튼 상호작용 처리 추가
|
||||||
|
|
||||||
|
### 4. i18n 반영
|
||||||
|
|
||||||
|
- `src/i18n/types.ts`
|
||||||
|
- `src/i18n/locales/en.ts`
|
||||||
|
- `src/i18n/locales/ko.ts`
|
||||||
|
|
||||||
|
음악 명령/재생 상태/오류 안내에 필요한 번역 키를 추가했다.
|
||||||
|
|
||||||
|
### 5. 테스트 추가
|
||||||
|
|
||||||
|
`tests/services/MusicService.test.ts`
|
||||||
|
|
||||||
|
- YouTube URL 파싱
|
||||||
|
- 재생 시간 포맷팅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `yarn build`
|
||||||
|
- `yarn test --runInBand`
|
||||||
|
|
||||||
|
두 명령 모두 정상 통과했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 한계
|
||||||
|
|
||||||
|
- 이번 단계는 단일 영상 단위 큐 처리 기준이다.
|
||||||
|
- YouTube 재생 목록 URL 전체 추가, 인덱스 삭제, 상세한 권한 제어는 아직 포함하지 않았다.
|
||||||
|
- 스트림 URL은 `youtubei.js`의 현재 응답 구조에 의존하므로 향후 YouTube 응답 변경 시 보완이 필요할 수 있다.
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Kord - YouTube 음악 재생 Phase 2 구현
|
||||||
|
|
||||||
|
## 변경 개요
|
||||||
|
|
||||||
|
YouTube 음악 재생 기능의 Phase 2를 구현했다.
|
||||||
|
|
||||||
|
이번 단계에서는 재생 목록 관리 기능을 확장하고, 재생 중 메시지에 진행 바 UI를 추가해 현재 재생 상태를 더 직관적으로 볼 수 있도록 개선했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위
|
||||||
|
|
||||||
|
- `/music remove index:<번호>`
|
||||||
|
- 대기열에서 원하는 곡 삭제
|
||||||
|
- `/music stop`
|
||||||
|
- 현재 재생 중지 + 대기열 비우기
|
||||||
|
- YouTube 재생목록 URL 추가
|
||||||
|
- `list=` 파라미터가 있는 링크를 재생목록으로 인식
|
||||||
|
- 최대 100곡까지 대기열에 추가
|
||||||
|
- 재생 메시지 UI 개선
|
||||||
|
- 현재 재생 시간
|
||||||
|
- 전체 길이
|
||||||
|
- 진행 바 텍스트 UI
|
||||||
|
- 10초 간격 자동 갱신
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 구현
|
||||||
|
|
||||||
|
### 1. MusicService 확장
|
||||||
|
|
||||||
|
`src/services/MusicService.ts`
|
||||||
|
|
||||||
|
- 길드 세션에 현재 곡 시작 시각(`nowPlayingStartedAt`)과 진행 바 갱신 인터벌(`progressInterval`) 추가
|
||||||
|
- `yt-dlp --dump-single-json --flat-playlist`를 사용해 재생목록 URL 파싱
|
||||||
|
- `remove()` 메서드 추가
|
||||||
|
- 현재 곡 진행률 계산 및 텍스트 진행 바 생성
|
||||||
|
- 재생 메시지를 10초 간격으로 업데이트
|
||||||
|
|
||||||
|
### 2. Slash Command 확장
|
||||||
|
|
||||||
|
`src/commands/music.ts`
|
||||||
|
|
||||||
|
- `/music remove`
|
||||||
|
- `/music stop`
|
||||||
|
- 재생목록 URL 응답 메시지 분기 추가
|
||||||
|
|
||||||
|
### 3. i18n 반영
|
||||||
|
|
||||||
|
- `src/i18n/types.ts`
|
||||||
|
- `src/i18n/locales/en.ts`
|
||||||
|
- `src/i18n/locales/ko.ts`
|
||||||
|
|
||||||
|
추가된 번역 키:
|
||||||
|
- remove / stop 명령 설명
|
||||||
|
- 인덱스 삭제 성공 / 범위 오류 안내
|
||||||
|
- 재생목록 추가 성공 메시지
|
||||||
|
- 진행 바 / 알 수 없는 길이 표기
|
||||||
|
|
||||||
|
### 4. 테스트 보강
|
||||||
|
|
||||||
|
`tests/services/MusicService.test.ts`
|
||||||
|
|
||||||
|
- YouTube 재생목록 URL 감지 테스트 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `yarn build`
|
||||||
|
- `yarn test --runInBand`
|
||||||
|
|
||||||
|
두 명령 모두 정상 통과했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 사항
|
||||||
|
|
||||||
|
- 진행 바는 길이를 알 수 있는 곡에만 10초 간격으로 갱신된다.
|
||||||
|
- 재생목록 URL은 최대 100곡까지 가져오도록 제한했다.
|
||||||
|
- 현재 재생 중인 곡은 `/music remove` 대상이 아니며, 대기열 곡만 삭제된다.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 2026-03-31 - YouTube Music Playback Phase 3 Implementation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 3 focused on playback controls and now-playing UX improvements.
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- pause and resume controls
|
||||||
|
- next-track preview in the now-playing message
|
||||||
|
- immediate control message refresh after queue and button actions
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Pause / Resume
|
||||||
|
|
||||||
|
- Added `/music pause`
|
||||||
|
- Added `/music resume`
|
||||||
|
- Added pause/resume control button toggle in the playback message
|
||||||
|
- Paused playback now freezes progress updates and resume restarts them
|
||||||
|
|
||||||
|
### 2. Next Track Preview
|
||||||
|
|
||||||
|
- The now-playing embed now shows the next queued track when one exists
|
||||||
|
- Queue length and next-track display are refreshed when tracks are added or removed
|
||||||
|
|
||||||
|
### 3. Immediate UI Refresh
|
||||||
|
|
||||||
|
- Button actions now use deferred updates and refresh the control message immediately
|
||||||
|
- Stop and leave actions move the playback message to the idle state without waiting for the periodic updater
|
||||||
|
- Queue mutations such as `add` and `remove` also refresh the control message
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/commands/music.ts`
|
||||||
|
- `src/services/MusicService.ts`
|
||||||
|
- `src/i18n/types.ts`
|
||||||
|
- `src/i18n/locales/en.ts`
|
||||||
|
- `src/i18n/locales/ko.ts`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `yarn build`
|
||||||
|
- `yarn test --runInBand`
|
||||||
|
|
||||||
|
Both commands completed successfully.
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
# 데이터베이스 테이블 구조
|
||||||
|
|
||||||
|
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| DB | PostgreSQL |
|
||||||
|
| ORM | Prisma (`prisma-client-js`) |
|
||||||
|
| 연결 | `DATABASE_URL` 환경 변수 |
|
||||||
|
|
||||||
|
## 열거형 (Enum)
|
||||||
|
|
||||||
|
### `SubscriptionTier`
|
||||||
|
|
||||||
|
구독 단계.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `FREE` | 기본 |
|
||||||
|
| `STANDARD` | 스탠다드 |
|
||||||
|
| `PRO` | 프로 |
|
||||||
|
| `PREMIUM` | 프리미엄 |
|
||||||
|
|
||||||
|
### `DeleteCondition`
|
||||||
|
|
||||||
|
임시 음성 채널 삭제 조건.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `OWNER_LEAVE` | 소유자 퇴장 시 |
|
||||||
|
| `EMPTY` | 비었을 때 (기본) |
|
||||||
|
|
||||||
|
### `EventStatus`
|
||||||
|
|
||||||
|
길드 이벤트 상태.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `SCHEDULED` | 예정 (기본) |
|
||||||
|
| `CANCELLED` | 취소 |
|
||||||
|
| `COMPLETED` | 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 목록
|
||||||
|
|
||||||
|
### `GuildConfig`
|
||||||
|
|
||||||
|
디스코드 길드별 봇 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `prefix` | `String` | 기본 `!` |
|
||||||
|
| `mimicEnabled` | `Boolean` | 기본 `false` |
|
||||||
|
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
|
||||||
|
| `locale` | `String?` | nullable |
|
||||||
|
| `createdAt` | `DateTime` | 생성 시각 |
|
||||||
|
| `updatedAt` | `DateTime` | 자동 갱신 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `InviteRole`
|
||||||
|
|
||||||
|
길드·초대 코드별 역할 매핑.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `id` | `String` | PK, UUID |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `inviteCode` | `String` | |
|
||||||
|
| `roleId` | `String` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**유니크:** `(guildId, inviteCode)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserSubscription`
|
||||||
|
|
||||||
|
사용자 구독 정보.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | PK |
|
||||||
|
| `tier` | `SubscriptionTier` | 기본 `FREE` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**관계:** `GuildOwnership[]` (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GuildOwnership`
|
||||||
|
|
||||||
|
구독 사용자가 소유하는 길드.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `ownerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VoiceGenerator`
|
||||||
|
|
||||||
|
음성 채널 생성기(부모 채널) 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `channelId` | `String` | PK |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `categoryId` | `String?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `guildId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `TempVoiceChannel`
|
||||||
|
|
||||||
|
생성된 임시 음성 채널.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `channelId` | `String` | PK |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `ownerId` | `String` | |
|
||||||
|
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `guildId`, `ownerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserVoiceProfile`
|
||||||
|
|
||||||
|
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | 복합 PK |
|
||||||
|
| `guildId` | `String` | 복합 PK |
|
||||||
|
| `customName` | `String?` | |
|
||||||
|
| `userLimit` | `Int?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**PK:** `(userId, guildId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserLocale`
|
||||||
|
|
||||||
|
사용자별 로케일.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | PK |
|
||||||
|
| `locale` | `String` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `AuditChannel`
|
||||||
|
|
||||||
|
감사 로그 전달 채널·비활성 카테고리.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `channelId` | `String` | |
|
||||||
|
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VoiceGuildConfig`
|
||||||
|
|
||||||
|
길드 단위 음성(임시 채널) 기본 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
|
||||||
|
| `defaultUserLimit` | `Int` | 기본 `0` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GuildEvent`
|
||||||
|
|
||||||
|
길드 일정/이벤트.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `id` | `String` | PK, UUID |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `title` | `String` | |
|
||||||
|
| `description` | `String?` | |
|
||||||
|
| `startsAt` | `DateTime` | |
|
||||||
|
| `timezone` | `String` | 기본 `Asia/Seoul` |
|
||||||
|
| `status` | `EventStatus` | 기본 `SCHEDULED` |
|
||||||
|
| `announcementChannelId` | `String?` | |
|
||||||
|
| `createdByUserId` | `String` | |
|
||||||
|
| `reminderEnabled` | `Boolean` | 기본 `true` |
|
||||||
|
| `reminderOffsets` | `Int[]` | 기본 `[]` |
|
||||||
|
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
|
||||||
|
| `remindedOneHour` | `Boolean` | 기본 `false` |
|
||||||
|
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
|
||||||
|
| `startedAnnounced` | `Boolean` | 기본 `false` |
|
||||||
|
| `announcedAt` | `DateTime?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관계 요약
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
UserSubscription ||--o{ GuildOwnership : "userId"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
|
||||||
|
|
||||||
|
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
|
||||||
|
|
||||||
|
## 스키마 변경 시
|
||||||
|
|
||||||
|
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
||||||
- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
|
- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
|
||||||
|
- [YouTube 鞚岇晠 鞛<>儩 旮半姤 旮绊殟鞎<E6AE9F> (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
|
||||||
|
|
||||||
|
|
||||||
## 아키텍처 및 정책 결정 (Decisions)
|
## 아키텍처 및 정책 결정 (Decisions)
|
||||||
|
|
@ -53,3 +54,6 @@
|
||||||
- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
|
- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
|
||||||
- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
|
- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
|
||||||
- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
|
- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
|
||||||
|
- [2026-03-30: YouTube 澜厩 犁积 Phase 1 备泅 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
|
||||||
|
- [2026-03-31: YouTube 澜厩 犁积 Phase 2 备泅 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
|
||||||
|
- [2026-03-31: YouTube 澜厩 犁积 Phase 3 备泅 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
"name": "kord",
|
"name": "kord",
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@discordjs/opus": "^0.10.0",
|
||||||
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@prisma/client": "6.4.1",
|
"@prisma/client": "6.4.1",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.3.1"
|
"dotenv": "^17.3.1",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"prism-media": "^1.3.5",
|
||||||
|
"youtubei.js": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { SupportedLocale, t } from '../i18n';
|
||||||
|
import { MusicService } from '../services/MusicService';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
function buildErrorMessage(locale: SupportedLocale, key: string) {
|
||||||
|
const message = t(locale, `commands.music.${key}`);
|
||||||
|
const resolution = t(locale, `commands.music.${key}Resolution`);
|
||||||
|
return resolution && resolution !== `commands.music.${key}Resolution`
|
||||||
|
? `${message}\n${resolution}`
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
content: string,
|
||||||
|
ephemeral = false,
|
||||||
|
) {
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.replied) {
|
||||||
|
await interaction.followUp({ content, ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content, ephemeral });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('music')
|
||||||
|
.setDescription('Play YouTube audio in voice channels.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '음성 채널에서 YouTube 오디오를 재생합니다.',
|
||||||
|
})
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('add')
|
||||||
|
.setDescription('Search YouTube or add a video URL to the queue.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
|
||||||
|
})
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('query')
|
||||||
|
.setDescription('Search query for YouTube')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: 'YouTube 검색어',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('url')
|
||||||
|
.setDescription('YouTube video URL')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: 'YouTube 영상 URL',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('queue')
|
||||||
|
.setDescription('Show the current music queue.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '현재 음악 재생 목록을 표시합니다.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('remove')
|
||||||
|
.setDescription('Remove a track from the upcoming queue.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '대기열에서 원하는 곡을 삭제합니다.',
|
||||||
|
})
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('index')
|
||||||
|
.setDescription('Queue index to remove')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '삭제할 대기열 인덱스',
|
||||||
|
})
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('pause')
|
||||||
|
.setDescription('Pause the currently playing track.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '현재 재생 중인 곡을 일시정지합니다.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('resume')
|
||||||
|
.setDescription('Resume the paused track.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '일시정지된 곡의 재생을 다시 시작합니다.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('skip')
|
||||||
|
.setDescription('Skip the currently playing track.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '현재 재생 중인 곡을 건너뜁니다.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('stop')
|
||||||
|
.setDescription('Stop playback and clear the queue.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '재생을 중지하고 대기열을 비웁니다.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('leave')
|
||||||
|
.setDescription('Disconnect the bot from the voice channel.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '봇을 음성 채널에서 내보냅니다.',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (subcommand === 'add') {
|
||||||
|
const query = interaction.options.getString('query');
|
||||||
|
const url = interaction.options.getString('url');
|
||||||
|
|
||||||
|
if ((!query && !url) || (query && url)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'addMutuallyExclusive'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textChannel = interaction.channel as any;
|
||||||
|
if (!textChannel?.send) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'errors.E3003.userMessage'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const result = query
|
||||||
|
? await MusicService.addFromQuery(member, textChannel, query, locale)
|
||||||
|
: await MusicService.addFromUrl(member, textChannel, url!, locale);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: result.tracksAdded > 1
|
||||||
|
? (result.startedNow
|
||||||
|
? t(locale, 'commands.music.playlistAddedNowPlaying', {
|
||||||
|
count: String(result.tracksAdded),
|
||||||
|
channel: `<#${result.voiceChannelId}>`,
|
||||||
|
})
|
||||||
|
: t(locale, 'commands.music.playlistAddedLater', {
|
||||||
|
count: String(result.tracksAdded),
|
||||||
|
}))
|
||||||
|
: (result.startedNow
|
||||||
|
? t(locale, 'commands.music.queueAddedNowPlaying', {
|
||||||
|
title: result.track.title,
|
||||||
|
channel: `<#${result.voiceChannelId}>`,
|
||||||
|
})
|
||||||
|
: t(locale, 'commands.music.queueAddedLater', {
|
||||||
|
title: result.track.title,
|
||||||
|
position: String(result.position),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'queue') {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [MusicService.getQueueEmbed(interaction.guildId!, locale)],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'remove') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = interaction.options.getInteger('index', true);
|
||||||
|
const removed = await MusicService.remove(interaction.guildId!, index);
|
||||||
|
if (!removed) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.music.queueRemoved', {
|
||||||
|
title: removed.title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'pause') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paused = await MusicService.pause(interaction.guildId!, locale);
|
||||||
|
if (!paused) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'commands.music.pauseSuccess') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'resume') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumed = await MusicService.resume(interaction.guildId!, locale);
|
||||||
|
if (!resumed) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'commands.music.resumeSuccess') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'skip') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = await MusicService.skip(interaction.guildId!);
|
||||||
|
if (!skipped) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'commands.music.skipSuccess') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'stop') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopped = await MusicService.stop(interaction.guildId!, locale);
|
||||||
|
if (!stopped) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'commands.music.stopSuccess') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'leave') {
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
if (!member.voice.channel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'notInVoice'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||||
|
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = await MusicService.leave(interaction.guildId!, locale);
|
||||||
|
if (!left) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'commands.music.leaveSuccess') });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in music command:', error);
|
||||||
|
|
||||||
|
const knownError = error instanceof Error ? error.message : '';
|
||||||
|
if (knownError === 'NOT_IN_VOICE') {
|
||||||
|
await respond(interaction, buildErrorMessage(locale, 'notInVoice'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (knownError === 'DIFFERENT_VOICE') {
|
||||||
|
await respond(interaction, buildErrorMessage(locale, 'differentVoiceChannel'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (knownError === 'NO_SEARCH_RESULTS') {
|
||||||
|
await respond(interaction, buildErrorMessage(locale, 'noSearchResults'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (knownError === 'INVALID_URL') {
|
||||||
|
await respond(interaction, buildErrorMessage(locale, 'invalidUrl'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (knownError === 'QUEUE_INDEX_OUT_OF_RANGE') {
|
||||||
|
await respond(interaction, buildErrorMessage(locale, 'queueRemoveOutOfRange'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await respond(interaction, t(locale, 'errors.E3003.userMessage'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'errors.E3003.userMessage'),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { getInteractionLocale } from '../i18n/localeHelper';
|
import { getInteractionLocale } from '../i18n/localeHelper';
|
||||||
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
|
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
|
||||||
|
import { MusicService } from '../services/MusicService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
|
|
@ -22,6 +23,12 @@ export default {
|
||||||
await command.execute(interaction, locale);
|
await command.execute(interaction, locale);
|
||||||
}, locale);
|
}, locale);
|
||||||
}
|
}
|
||||||
|
else if (interaction.isButton() && interaction.customId.startsWith('music_')) {
|
||||||
|
const locale = await getInteractionLocale(interaction);
|
||||||
|
await withErrorHandler(interaction, async () => {
|
||||||
|
await MusicService.handleControlInteraction(interaction, locale);
|
||||||
|
}, locale);
|
||||||
|
}
|
||||||
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
|
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
|
||||||
const locale = await getInteractionLocale(interaction);
|
const locale = await getInteractionLocale(interaction);
|
||||||
await withErrorHandler(interaction, async () => {
|
await withErrorHandler(interaction, async () => {
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,73 @@ export const en: TranslationSchema = {
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
music: {
|
||||||
|
description: 'Play YouTube audio in voice channels.',
|
||||||
|
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||||
|
queueDescription: 'Show the current music queue.',
|
||||||
|
removeDescription: 'Remove a track from the upcoming queue.',
|
||||||
|
pauseDescription: 'Pause the currently playing track.',
|
||||||
|
resumeDescription: 'Resume the paused track.',
|
||||||
|
skipDescription: 'Skip the currently playing track.',
|
||||||
|
stopDescription: 'Stop playback and clear the queue.',
|
||||||
|
leaveDescription: 'Disconnect the bot from the voice channel.',
|
||||||
|
queryDescription: 'Search query for YouTube',
|
||||||
|
urlDescription: 'YouTube video URL',
|
||||||
|
indexDescription: 'Queue index to remove',
|
||||||
|
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
|
||||||
|
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
|
||||||
|
notInVoice: 'You must be in a voice channel to use music commands.',
|
||||||
|
notInVoiceResolution: 'Join a voice channel first, then try again.',
|
||||||
|
differentVoiceChannel: 'Music is already being used in another voice channel.',
|
||||||
|
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
|
||||||
|
noSearchResults: 'No YouTube results were found for that query.',
|
||||||
|
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
|
||||||
|
invalidUrl: 'The provided YouTube URL is invalid.',
|
||||||
|
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
|
||||||
|
noActiveSession: 'There is no active music session in this server.',
|
||||||
|
noActiveSessionResolution: 'Add a track first to start playback.',
|
||||||
|
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
|
||||||
|
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
|
||||||
|
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
|
||||||
|
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
|
||||||
|
queueTitle: 'Music Queue',
|
||||||
|
queueEmpty: 'The music queue is currently empty.',
|
||||||
|
queueNowPlaying: 'Now Playing',
|
||||||
|
queueUpcoming: 'Up Next',
|
||||||
|
queueMoreItems: '...and **{{count}}** more track(s).',
|
||||||
|
queueRemoved: 'Removed **{{title}}** from the queue.',
|
||||||
|
queueRemoveOutOfRange: 'That queue index does not exist.',
|
||||||
|
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
|
||||||
|
pauseSuccess: 'Paused the current track.',
|
||||||
|
resumeSuccess: 'Resumed playback.',
|
||||||
|
skipSuccess: 'Skipped the current track.',
|
||||||
|
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
|
||||||
|
stopSuccess: 'Stopped playback and cleared the queue.',
|
||||||
|
playbackStartedTitle: 'Now Playing',
|
||||||
|
playbackIdleTitle: 'Queue Finished',
|
||||||
|
playbackIdleBody: 'There are no more tracks in the queue.',
|
||||||
|
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
|
||||||
|
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
|
||||||
|
streamUnavailable: 'Could not load a playable audio stream for this video.',
|
||||||
|
streamUnavailableResolution: 'Try another video or add the track again later.',
|
||||||
|
requestedBy: 'Requested by',
|
||||||
|
duration: 'Duration',
|
||||||
|
progress: 'Progress',
|
||||||
|
source: 'Source',
|
||||||
|
status: 'Status',
|
||||||
|
queueLength: 'Queue Length',
|
||||||
|
nextTrack: 'Next Track',
|
||||||
|
statusPlaying: 'Playing',
|
||||||
|
statusPaused: 'Paused',
|
||||||
|
unknownDuration: 'Unknown',
|
||||||
|
buttons: {
|
||||||
|
pause: '⏸ Pause',
|
||||||
|
resume: '▶ Resume',
|
||||||
|
skip: '⏭ Skip',
|
||||||
|
stop: '⏹ Stop',
|
||||||
|
leave: '👋 Leave',
|
||||||
|
},
|
||||||
|
},
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: 'Bot Permission Audit Report',
|
title: 'Bot Permission Audit Report',
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,73 @@ export const ko: TranslationSchema = {
|
||||||
status: '상태',
|
status: '상태',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
music: {
|
||||||
|
description: 'Play YouTube audio in voice channels.',
|
||||||
|
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||||
|
queueDescription: 'Show the current music queue.',
|
||||||
|
removeDescription: 'Remove a track from the upcoming queue.',
|
||||||
|
pauseDescription: 'Pause the currently playing track.',
|
||||||
|
resumeDescription: 'Resume the paused track.',
|
||||||
|
skipDescription: 'Skip the currently playing track.',
|
||||||
|
stopDescription: 'Stop playback and clear the queue.',
|
||||||
|
leaveDescription: 'Disconnect the bot from the voice channel.',
|
||||||
|
queryDescription: 'Search query for YouTube',
|
||||||
|
urlDescription: 'YouTube video URL',
|
||||||
|
indexDescription: 'Queue index to remove',
|
||||||
|
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
|
||||||
|
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
|
||||||
|
notInVoice: 'You must be in a voice channel to use music commands.',
|
||||||
|
notInVoiceResolution: 'Join a voice channel first, then try again.',
|
||||||
|
differentVoiceChannel: 'Music is already being used in another voice channel.',
|
||||||
|
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
|
||||||
|
noSearchResults: 'No YouTube results were found for that query.',
|
||||||
|
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
|
||||||
|
invalidUrl: 'The provided YouTube URL is invalid.',
|
||||||
|
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
|
||||||
|
noActiveSession: 'There is no active music session in this server.',
|
||||||
|
noActiveSessionResolution: 'Add a track first to start playback.',
|
||||||
|
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
|
||||||
|
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
|
||||||
|
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
|
||||||
|
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
|
||||||
|
queueTitle: 'Music Queue',
|
||||||
|
queueEmpty: 'The music queue is currently empty.',
|
||||||
|
queueNowPlaying: 'Now Playing',
|
||||||
|
queueUpcoming: 'Up Next',
|
||||||
|
queueMoreItems: '...and **{{count}}** more track(s).',
|
||||||
|
queueRemoved: 'Removed **{{title}}** from the queue.',
|
||||||
|
queueRemoveOutOfRange: 'That queue index does not exist.',
|
||||||
|
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
|
||||||
|
pauseSuccess: 'Paused the current track.',
|
||||||
|
resumeSuccess: 'Resumed playback.',
|
||||||
|
skipSuccess: 'Skipped the current track.',
|
||||||
|
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
|
||||||
|
stopSuccess: 'Stopped playback and cleared the queue.',
|
||||||
|
playbackStartedTitle: 'Now Playing',
|
||||||
|
playbackIdleTitle: 'Queue Finished',
|
||||||
|
playbackIdleBody: 'There are no more tracks in the queue.',
|
||||||
|
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
|
||||||
|
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
|
||||||
|
streamUnavailable: 'Could not load a playable audio stream for this video.',
|
||||||
|
streamUnavailableResolution: 'Try another video or add the track again later.',
|
||||||
|
requestedBy: 'Requested by',
|
||||||
|
duration: 'Duration',
|
||||||
|
progress: 'Progress',
|
||||||
|
source: 'Source',
|
||||||
|
status: 'Status',
|
||||||
|
queueLength: 'Queue Length',
|
||||||
|
nextTrack: 'Next Track',
|
||||||
|
statusPlaying: 'Playing',
|
||||||
|
statusPaused: 'Paused',
|
||||||
|
unknownDuration: 'Unknown',
|
||||||
|
buttons: {
|
||||||
|
pause: 'Pause',
|
||||||
|
resume: 'Resume',
|
||||||
|
skip: 'Skip',
|
||||||
|
stop: 'Stop',
|
||||||
|
leave: 'Leave',
|
||||||
|
},
|
||||||
|
},
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: '봇 권한 진단 보고서',
|
title: '봇 권한 진단 보고서',
|
||||||
channel: '채널',
|
channel: '채널',
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,73 @@ export interface TranslationSchema {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
music: {
|
||||||
|
description: string;
|
||||||
|
addDescription: string;
|
||||||
|
queueDescription: string;
|
||||||
|
removeDescription: string;
|
||||||
|
pauseDescription: string;
|
||||||
|
resumeDescription: string;
|
||||||
|
skipDescription: string;
|
||||||
|
stopDescription: string;
|
||||||
|
leaveDescription: string;
|
||||||
|
queryDescription: string;
|
||||||
|
urlDescription: string;
|
||||||
|
indexDescription: string;
|
||||||
|
addMutuallyExclusive: string;
|
||||||
|
addMutuallyExclusiveResolution: string;
|
||||||
|
notInVoice: string;
|
||||||
|
notInVoiceResolution: string;
|
||||||
|
differentVoiceChannel: string;
|
||||||
|
differentVoiceChannelResolution: string;
|
||||||
|
noSearchResults: string;
|
||||||
|
noSearchResultsResolution: string;
|
||||||
|
invalidUrl: string;
|
||||||
|
invalidUrlResolution: string;
|
||||||
|
noActiveSession: string;
|
||||||
|
noActiveSessionResolution: string;
|
||||||
|
queueAddedNowPlaying: string;
|
||||||
|
queueAddedLater: string;
|
||||||
|
playlistAddedNowPlaying: string;
|
||||||
|
playlistAddedLater: string;
|
||||||
|
queueTitle: string;
|
||||||
|
queueEmpty: string;
|
||||||
|
queueNowPlaying: string;
|
||||||
|
queueUpcoming: string;
|
||||||
|
queueMoreItems: string;
|
||||||
|
queueRemoved: string;
|
||||||
|
queueRemoveOutOfRange: string;
|
||||||
|
queueRemoveOutOfRangeResolution: string;
|
||||||
|
pauseSuccess: string;
|
||||||
|
resumeSuccess: string;
|
||||||
|
skipSuccess: string;
|
||||||
|
leaveSuccess: string;
|
||||||
|
stopSuccess: string;
|
||||||
|
playbackStartedTitle: string;
|
||||||
|
playbackIdleTitle: string;
|
||||||
|
playbackIdleBody: string;
|
||||||
|
playbackFailed: string;
|
||||||
|
playbackFailedResolution: string;
|
||||||
|
streamUnavailable: string;
|
||||||
|
streamUnavailableResolution: string;
|
||||||
|
requestedBy: string;
|
||||||
|
duration: string;
|
||||||
|
progress: string;
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
queueLength: string;
|
||||||
|
nextTrack: string;
|
||||||
|
statusPlaying: string;
|
||||||
|
statusPaused: string;
|
||||||
|
unknownDuration: string;
|
||||||
|
buttons: {
|
||||||
|
pause: string;
|
||||||
|
resume: string;
|
||||||
|
skip: string;
|
||||||
|
stop: string;
|
||||||
|
leave: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
permissionAudit: {
|
permissionAudit: {
|
||||||
title: string;
|
title: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { extractYouTubeVideoId, formatDuration, isYouTubePlaylistUrl } from '../../src/services/MusicService';
|
||||||
|
|
||||||
|
describe('MusicService helpers', () => {
|
||||||
|
it('extracts a video id from standard watch URLs', () => {
|
||||||
|
expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a video id from short URLs', () => {
|
||||||
|
expect(extractYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid URLs', () => {
|
||||||
|
expect(extractYouTubeVideoId('https://example.com/watch?v=dQw4w9WgXcQ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects playlist URLs', () => {
|
||||||
|
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL1234567890')).toBe(true);
|
||||||
|
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1234567890')).toBe(true);
|
||||||
|
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats durations consistently', () => {
|
||||||
|
expect(formatDuration(65)).toBe('01:05');
|
||||||
|
expect(formatDuration(3665)).toBe('1:01:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue