Compare commits
No commits in common. "1f91bfb9bf9d494302e21600364639a310b4f538" and "e43af4f94414262c43d5abc8ca3f63dc69445272" have entirely different histories.
1f91bfb9bf
...
e43af4f944
|
|
@ -1,314 +0,0 @@
|
||||||
# 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) |
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
# 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 응답 변경 시 보완이 필요할 수 있다.
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# 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` 대상이 아니며, 대기열 곡만 삭제된다.
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
# 데이터베이스 테이블 구조
|
|
||||||
|
|
||||||
이 문서는 [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,7 +23,6 @@
|
||||||
- [에러 안내 기능 기획서 (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)
|
||||||
|
|
@ -54,6 +53,3 @@
|
||||||
- [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,15 +2,9 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
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,7 +8,6 @@ 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,
|
||||||
|
|
@ -23,12 +22,6 @@ 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,73 +191,6 @@ 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,73 +191,6 @@ 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,73 +145,6 @@ 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
|
|
@ -1,26 +0,0 @@
|
||||||
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