From 85f5057fd7c88e61994d289ef408d0dcb9e2c2fa Mon Sep 17 00:00:00 2001 From: MyungHyun Date: Tue, 31 Mar 2026 02:12:27 +0900 Subject: [PATCH] Implement YouTube music playback workflow --- Docs/Plans/YouTube_Music_Playback_Plan.md | 314 +++++ ...be_Music_Playback_Phase1_Implementation.md | 87 ++ ...be_Music_Playback_Phase2_Implementation.md | 81 ++ ...be_Music_Playback_Phase3_Implementation.md | 45 + Docs/index.md | 4 + package.json | 7 +- src/commands/music.ts | 431 +++++++ src/events/interactionCreate.ts | 7 + src/i18n/locales/en.ts | 67 ++ src/i18n/locales/ko.ts | 67 ++ src/i18n/types.ts | 67 ++ src/services/MusicService.ts | 1039 +++++++++++++++++ tests/services/MusicService.test.ts | 26 + yarn.lock | 661 ++++++++++- 14 files changed, 2893 insertions(+), 10 deletions(-) create mode 100644 Docs/Plans/YouTube_Music_Playback_Plan.md create mode 100644 Docs/WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md create mode 100644 Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md create mode 100644 Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md create mode 100644 src/commands/music.ts create mode 100644 src/services/MusicService.ts create mode 100644 tests/services/MusicService.test.ts diff --git a/Docs/Plans/YouTube_Music_Playback_Plan.md b/Docs/Plans/YouTube_Music_Playback_Plan.md new file mode 100644 index 0000000..3b35c2c --- /dev/null +++ b/Docs/Plans/YouTube_Music_Playback_Plan.md @@ -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:` +- `/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) | diff --git a/Docs/WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md b/Docs/WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md new file mode 100644 index 0000000..005482a --- /dev/null +++ b/Docs/WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.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 응답 변경 시 보완이 필요할 수 있다. diff --git a/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md b/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md new file mode 100644 index 0000000..af78842 --- /dev/null +++ b/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md @@ -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` 대상이 아니며, 대기열 곡만 삭제된다. diff --git a/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md b/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md new file mode 100644 index 0000000..3bf1796 --- /dev/null +++ b/Docs/WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md @@ -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. diff --git a/Docs/index.md b/Docs/index.md index 8beffbf..0554c73 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -23,6 +23,7 @@ - [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) - [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) - [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md) +- [YouTube 음악 재생 기능 기획안 (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md) ## 아키텍처 및 정책 결정 (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 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: 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) diff --git a/package.json b/package.json index 90e3931..82709db 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,15 @@ "name": "kord", "packageManager": "yarn@4.9.1", "dependencies": { + "@discordjs/opus": "^0.10.0", + "@discordjs/voice": "^0.19.2", "@prisma/client": "6.4.1", "discord.js": "^14.25.1", "dotenv": "^17.3.1", - "ioredis": "^5.10.1" + "ffmpeg-static": "^5.3.0", + "ioredis": "^5.10.1", + "prism-media": "^1.3.5", + "youtubei.js": "^17.0.1" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/src/commands/music.ts b/src/commands/music.ts new file mode 100644 index 0000000..0beb3c4 --- /dev/null +++ b/src/commands/music.ts @@ -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, + }); + } + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 600eb81..68e3ef2 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -8,6 +8,7 @@ import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter'; import { t } from '../i18n'; import { getInteractionLocale } from '../i18n/localeHelper'; import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler'; +import { MusicService } from '../services/MusicService'; export default { name: Events.InteractionCreate, @@ -22,6 +23,12 @@ export default { await command.execute(interaction, 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_')) { const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3b5865c..b73f7c1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -191,6 +191,73 @@ export const en: 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: { title: 'Bot Permission Audit Report', channel: 'Channel', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index d64eef3..e08d74c 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -191,6 +191,73 @@ export const ko: TranslationSchema = { 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: { title: '봇 권한 진단 보고서', channel: '채널', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 883850c..fddfb5b 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -145,6 +145,73 @@ export interface TranslationSchema { 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: { title: string; channel: string; diff --git a/src/services/MusicService.ts b/src/services/MusicService.ts new file mode 100644 index 0000000..a2a2f69 --- /dev/null +++ b/src/services/MusicService.ts @@ -0,0 +1,1039 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + EmbedBuilder, + GuildMember, +} from 'discord.js'; +import { + AudioPlayer, + AudioPlayerStatus, + NoSubscriberBehavior, + StreamType, + VoiceConnection, + VoiceConnectionStatus, + createAudioPlayer, + createAudioResource, + entersState, + joinVoiceChannel, +} from '@discordjs/voice'; +import ffmpegPath from 'ffmpeg-static'; +import { ChildProcess, execFile, spawn } from 'child_process'; +import { promisify } from 'util'; +import { SupportedLocale, t } from '../i18n'; +import { logger } from '../utils/logger'; + +const execFileAsync = promisify(execFile); + +type MusicTextChannel = { + id: string; + send: (options: Record) => Promise; +}; + +interface MusicQueueItem { + videoId: string; + title: string; + durationSec: number | null; + durationText: string; + sourceUrl: string; + requestedByUserId: string; + requestedByName: string; +} + +interface PlaylistExtractionResult { + title: string | null; + tracks: Array<{ + videoId: string; + title: string; + durationSec: number | null; + durationText: string | null; + }>; +} + +interface GuildMusicSession { + guildId: string; + voiceChannelId: string; + textChannel: MusicTextChannel; + locale: SupportedLocale; + connection: VoiceConnection; + player: AudioPlayer; + queue: MusicQueueItem[]; + nowPlaying: MusicQueueItem | null; + nowPlayingStartedAt: number | null; + pausedStartedAt: number | null; + accumulatedPausedMs: number; + ffmpegProcess: ChildProcess | null; + controlMessage: any | null; + progressInterval: NodeJS.Timeout | null; +} + +type SearchVideoResult = { + type?: string; + id?: string; + video_id?: string; + title?: { text?: string; toString?: () => string } | string; + duration?: { text?: string } | string; +}; + +export function extractYouTubeVideoId(raw: string): string | null { + const trimmed = raw.trim(); + if (/^[\w-]{11}$/.test(trimmed)) { + return trimmed; + } + + try { + const url = new URL(trimmed); + const host = url.hostname.replace(/^www\./, ''); + + if (host === 'youtu.be') { + const id = url.pathname.split('/').filter(Boolean)[0]; + return id && /^[\w-]{11}$/.test(id) ? id : null; + } + + if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') { + if (url.pathname === '/watch') { + const id = url.searchParams.get('v'); + return id && /^[\w-]{11}$/.test(id) ? id : null; + } + + const segments = url.pathname.split('/').filter(Boolean); + const candidate = segments[1]; + if ( + segments.length >= 2 && + ['embed', 'shorts', 'live'].includes(segments[0]) && + candidate && + /^[\w-]{11}$/.test(candidate) + ) { + return candidate; + } + } + } catch { + return null; + } + + return null; +} + +export function isYouTubePlaylistUrl(raw: string): boolean { + try { + const url = new URL(raw.trim()); + const host = url.hostname.replace(/^www\./, ''); + + if (!['youtube.com', 'm.youtube.com', 'music.youtube.com'].includes(host)) { + return false; + } + + return Boolean(url.searchParams.get('list')); + } catch { + return false; + } +} + +function parseDurationSeconds(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && /^\d+$/.test(value)) { + return Number(value); + } + + return null; +} + +function parseDurationTextToSeconds(value?: string | null): number | null { + if (!value) { + return null; + } + + const parts = value + .split(':') + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) { + return null; + } + + const numbers = parts.map(Number); + if (numbers.length === 3) { + return (numbers[0] * 3600) + (numbers[1] * 60) + numbers[2]; + } + + if (numbers.length === 2) { + return (numbers[0] * 60) + numbers[1]; + } + + if (numbers.length === 1) { + return numbers[0]; + } + + return null; +} + +export function formatDuration(seconds: number | null, fallback?: string | null): string { + if (seconds === null || !Number.isFinite(seconds)) { + return fallback || 'Unknown'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return [hours, minutes, secs].map((value, index) => (index === 0 ? String(value) : String(value).padStart(2, '0'))).join(':'); + } + + return [minutes, secs].map((value) => String(value).padStart(2, '0')).join(':'); +} + +export class MusicService { + private static sessions = new Map(); + private static youtubePromise: Promise | null = null; + private static readonly FFMPEG_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'; + private static readonly PYTHON_EXECUTABLE = + 'C:\\Users\\NSG-C0522\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe'; + + private static async getYouTube() { + if (!this.youtubePromise) { + this.youtubePromise = import('youtubei.js').then(({ Innertube }) => Innertube.create()); + } + + return this.youtubePromise; + } + + static async addFromQuery(member: GuildMember, textChannel: MusicTextChannel, query: string, locale: SupportedLocale) { + const youtube = await this.getYouTube(); + const search = await youtube.search(query, { type: 'video' }); + const video = search.results.find((item: SearchVideoResult) => item.type === 'Video' && (item.id || item.video_id)) as SearchVideoResult | undefined; + + if (!video) { + throw new Error('NO_SEARCH_RESULTS'); + } + + const videoId = video.id || video.video_id; + if (!videoId) { + throw new Error('NO_SEARCH_RESULTS'); + } + + const title = + typeof video.title === 'string' + ? video.title + : video.title?.text || video.title?.toString?.() || videoId; + const durationText = typeof video.duration === 'string' ? video.duration : video.duration?.text; + + const track = await this.createQueueItem(member, videoId, title, durationText); + return this.enqueueTracks(member, textChannel, [track], locale); + } + + static async addFromUrl(member: GuildMember, textChannel: MusicTextChannel, rawUrl: string, locale: SupportedLocale) { + if (isYouTubePlaylistUrl(rawUrl)) { + const playlist = await this.extractPlaylist(rawUrl); + if (playlist.tracks.length === 0) { + throw new Error('NO_SEARCH_RESULTS'); + } + + const tracks = playlist.tracks.map((track) => ({ + videoId: track.videoId, + title: track.title, + durationSec: track.durationSec, + durationText: formatDuration(track.durationSec, track.durationText), + sourceUrl: `https://www.youtube.com/watch?v=${track.videoId}`, + requestedByUserId: member.id, + requestedByName: member.displayName, + })); + + return this.enqueueTracks(member, textChannel, tracks, locale); + } + + const videoId = extractYouTubeVideoId(rawUrl); + if (!videoId) { + throw new Error('INVALID_URL'); + } + + const track = await this.createQueueItem(member, videoId); + return this.enqueueTracks(member, textChannel, [track], locale); + } + + static getQueueEmbed(guildId: string, locale: SupportedLocale) { + const session = this.sessions.get(guildId); + const embed = new EmbedBuilder() + .setColor(0xff5555) + .setTitle(t(locale, 'commands.music.queueTitle')); + + if (!session || (!session.nowPlaying && session.queue.length === 0)) { + embed.setDescription(t(locale, 'commands.music.queueEmpty')); + return embed; + } + + if (session.nowPlaying) { + embed.addFields({ + name: t(locale, 'commands.music.queueNowPlaying'), + value: this.formatTrackLine(session.nowPlaying), + }); + } + + if (session.queue.length > 0) { + const visibleTracks = session.queue.slice(0, 10); + embed.addFields({ + name: t(locale, 'commands.music.queueUpcoming'), + value: visibleTracks + .map((track, index) => `\`${index + 1}\` ${this.formatTrackLine(track)}`) + .join('\n'), + }); + + if (session.queue.length > visibleTracks.length) { + embed.addFields({ + name: '\u200b', + value: t(locale, 'commands.music.queueMoreItems', { + count: String(session.queue.length - visibleTracks.length), + }), + }); + } + } + + return embed; + } + + static getActiveVoiceChannelId(guildId: string) { + return this.sessions.get(guildId)?.voiceChannelId || null; + } + + static async skip(guildId: string) { + const session = this.sessions.get(guildId); + if (!session || !session.nowPlaying) { + return false; + } + + session.player.stop(true); + return true; + } + + static async pause(guildId: string, locale: SupportedLocale) { + const session = this.sessions.get(guildId); + if (!session || !session.nowPlaying) { + return false; + } + + if (this.isPaused(session)) { + return true; + } + + session.locale = locale; + session.player.pause(true); + if (!session.pausedStartedAt) { + session.pausedStartedAt = Date.now(); + } + + await this.refreshControlMessage(session); + return true; + } + + static async resume(guildId: string, locale: SupportedLocale) { + const session = this.sessions.get(guildId); + if (!session || !session.nowPlaying) { + return false; + } + + session.locale = locale; + if (session.pausedStartedAt) { + session.accumulatedPausedMs += Date.now() - session.pausedStartedAt; + session.pausedStartedAt = null; + } + + session.player.unpause(); + await this.refreshControlMessage(session); + return true; + } + + static async stop(guildId: string, locale: SupportedLocale) { + const session = this.sessions.get(guildId); + if (!session) { + return false; + } + + session.queue = []; + session.locale = locale; + session.player.stop(true); + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + session.pausedStartedAt = null; + session.accumulatedPausedMs = 0; + await this.renderIdleState(session); + return true; + } + + static async remove(guildId: string, index: number) { + const session = this.sessions.get(guildId); + if (!session || session.queue.length === 0) { + return null; + } + + const queueIndex = index - 1; + if (queueIndex < 0 || queueIndex >= session.queue.length) { + throw new Error('QUEUE_INDEX_OUT_OF_RANGE'); + } + + const removed = session.queue.splice(queueIndex, 1)[0] ?? null; + await this.refreshControlMessage(session); + return removed; + } + + static async leave(guildId: string, locale: SupportedLocale) { + const session = this.sessions.get(guildId); + if (!session) { + return false; + } + + session.queue = []; + session.locale = locale; + this.cleanupProcess(session); + session.player.stop(true); + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + session.pausedStartedAt = null; + session.accumulatedPausedMs = 0; + session.connection.destroy(); + await this.renderIdleState(session); + this.sessions.delete(guildId); + return true; + } + + static async handleControlInteraction(interaction: ButtonInteraction, locale: SupportedLocale) { + const session = interaction.guildId ? this.sessions.get(interaction.guildId) : null; + if (!interaction.guildId || !session) { + await interaction.update({ + components: [this.buildFallbackControlRow(interaction.guildId ?? 'stale')], + }); + await interaction.followUp({ + content: t(locale, 'commands.music.noActiveSession'), + ephemeral: true, + }); + return; + } + + const member = interaction.member as GuildMember; + if (!member.voice.channelId || member.voice.channelId !== session.voiceChannelId) { + await interaction.reply({ + content: t(locale, 'commands.music.differentVoiceChannel'), + ephemeral: true, + }); + return; + } + + const action = interaction.customId.split('_')[1]; + await interaction.deferUpdate(); + + if (action === 'pause') { + await this.pause(interaction.guildId, locale); + return; + } + + if (action === 'resume') { + await this.resume(interaction.guildId, locale); + return; + } + + if (action === 'skip') { + await this.skip(interaction.guildId); + return; + } + + if (action === 'stop') { + await this.stop(interaction.guildId, locale); + return; + } + + if (action === 'leave') { + await this.leave(interaction.guildId, locale); + } + } + + private static async createQueueItem( + member: GuildMember, + videoId: string, + fallbackTitle?: string, + fallbackDurationText?: string, + ): Promise { + const youtube = await this.getYouTube(); + const info = await youtube.getBasicInfo(videoId); + const basicInfo = info.basic_info as Record | undefined; + const durationSec = parseDurationSeconds(basicInfo?.duration_seconds) ?? parseDurationTextToSeconds(fallbackDurationText); + + return { + videoId, + title: typeof basicInfo?.title === 'string' ? basicInfo.title : fallbackTitle || videoId, + durationSec, + durationText: formatDuration(durationSec, fallbackDurationText), + sourceUrl: `https://www.youtube.com/watch?v=${videoId}`, + requestedByUserId: member.id, + requestedByName: member.displayName, + }; + } + + private static async enqueueTracks( + member: GuildMember, + textChannel: MusicTextChannel, + tracks: MusicQueueItem[], + locale: SupportedLocale, + ) { + const session = await this.ensureSession(member, textChannel, locale); + const shouldStartNow = !session.nowPlaying; + + session.locale = locale; + session.textChannel = textChannel; + const previousLength = session.queue.length; + session.queue.push(...tracks); + + if (shouldStartNow) { + void this.playNext(session.guildId); + } else { + await this.refreshControlMessage(session); + } + + return { + track: tracks[0], + tracksAdded: tracks.length, + startedNow: shouldStartNow, + voiceChannelId: session.voiceChannelId, + position: previousLength + 1, + }; + } + + private static async ensureSession(member: GuildMember, textChannel: MusicTextChannel, locale: SupportedLocale) { + const voiceChannel = member.voice.channel; + if (!voiceChannel) { + throw new Error('NOT_IN_VOICE'); + } + + const existing = this.sessions.get(member.guild.id); + if (existing) { + if (existing.voiceChannelId !== voiceChannel.id) { + throw new Error('DIFFERENT_VOICE'); + } + + existing.textChannel = textChannel; + existing.locale = locale; + return existing; + } + + const connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: member.guild.id, + adapterCreator: member.guild.voiceAdapterCreator as any, + selfDeaf: false, + }); + + await entersState(connection, VoiceConnectionStatus.Ready, 20_000); + + const player = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + }, + }); + + connection.subscribe(player); + + const session: GuildMusicSession = { + guildId: member.guild.id, + voiceChannelId: voiceChannel.id, + textChannel, + locale, + connection, + player, + queue: [], + nowPlaying: null, + nowPlayingStartedAt: null, + pausedStartedAt: null, + accumulatedPausedMs: 0, + ffmpegProcess: null, + controlMessage: null, + progressInterval: null, + }; + + this.bindSessionEvents(session); + this.sessions.set(member.guild.id, session); + return session; + } + + private static bindSessionEvents(session: GuildMusicSession) { + session.player.on(AudioPlayerStatus.Idle, () => { + void this.onTrackFinished(session.guildId); + }); + + session.player.on('error', (error) => { + logger.error(`Music player error in guild ${session.guildId}:`, error); + void this.notifyPlaybackFailure(session); + }); + + session.connection.on(VoiceConnectionStatus.Disconnected, () => { + this.cleanupProcess(session); + this.sessions.delete(session.guildId); + }); + } + + private static async onTrackFinished(guildId: string) { + const session = this.sessions.get(guildId); + if (!session) { + return; + } + + this.cleanupProcess(session); + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + session.pausedStartedAt = null; + session.accumulatedPausedMs = 0; + + if (session.queue.length === 0) { + await this.renderIdleState(session); + return; + } + + await this.playNext(guildId); + } + + private static async notifyPlaybackFailure(session: GuildMusicSession) { + const failedTrack = session.nowPlaying; + this.cleanupProcess(session); + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + session.pausedStartedAt = null; + session.accumulatedPausedMs = 0; + + if (failedTrack) { + await this.safeSend(session.textChannel, { + content: `${t(session.locale, 'commands.music.playbackFailed', { title: failedTrack.title })}\n${t(session.locale, 'commands.music.playbackFailedResolution')}`, + }); + } + + if (session.queue.length > 0) { + await this.playNext(session.guildId); + } else { + await this.renderIdleState(session); + } + } + + private static async playNext(guildId: string) { + const session = this.sessions.get(guildId); + if (!session) { + return; + } + + const nextTrack = session.queue.shift(); + if (!nextTrack) { + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + await this.renderIdleState(session); + return; + } + + session.nowPlaying = nextTrack; + session.nowPlayingStartedAt = Date.now(); + session.pausedStartedAt = null; + session.accumulatedPausedMs = 0; + + try { + const streamUrl = await this.resolveStreamUrl(nextTrack.videoId); + const process = this.spawnFfmpeg(streamUrl); + session.ffmpegProcess = process; + + const resource = createAudioResource(process.stdout, { + inputType: StreamType.Raw, + metadata: nextTrack, + }); + + session.player.play(resource); + await this.renderNowPlaying(session); + } catch (error) { + logger.error(`Failed to start playback for ${nextTrack.videoId}:`, error); + await this.safeSend(session.textChannel, { + content: `${t(session.locale, 'commands.music.streamUnavailable')}\n${t(session.locale, 'commands.music.streamUnavailableResolution')}`, + }); + session.nowPlaying = null; + session.nowPlayingStartedAt = null; + await this.playNext(guildId); + } + } + + private static async resolveStreamUrl(videoId: string): Promise { + const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; + + try { + const { stdout } = await execFileAsync( + this.PYTHON_EXECUTABLE, + [ + '-m', + 'yt_dlp', + '--no-playlist', + '--no-warnings', + '--extractor-args', + 'youtube:player_client=android,web', + '-f', + 'bestaudio/best', + '-g', + videoUrl, + ], + { + windowsHide: true, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }, + ); + + const resolvedUrl = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith('http')); + + if (resolvedUrl) { + return resolvedUrl; + } + } catch (error) { + logger.warn(`yt-dlp stream extraction failed for ${videoId}:`, error); + } + + const youtube = await this.getYouTube(); + const info = await youtube.getInfo(videoId); + const streamingData = info.streaming_data as Record | undefined; + const serverAbrUrl = typeof streamingData?.server_abr_streaming_url === 'string' ? streamingData.server_abr_streaming_url : null; + const hlsManifestUrl = typeof streamingData?.hls_manifest_url === 'string' ? streamingData.hls_manifest_url : null; + const streamUrl = hlsManifestUrl || serverAbrUrl; + + if (!streamUrl) { + throw new Error('STREAM_UNAVAILABLE'); + } + + return streamUrl; + } + + private static async extractPlaylist(rawUrl: string): Promise { + const { stdout } = await execFileAsync( + this.PYTHON_EXECUTABLE, + [ + '-m', + 'yt_dlp', + '--flat-playlist', + '--dump-single-json', + '--no-warnings', + '--playlist-end', + '100', + rawUrl, + ], + { + windowsHide: true, + timeout: 30_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + + const parsed = JSON.parse(stdout) as { + title?: string; + entries?: Array<{ + id?: string; + title?: string; + duration?: number | null; + duration_string?: string | null; + }>; + }; + + return { + title: parsed.title ?? null, + tracks: (parsed.entries ?? []) + .filter((entry) => entry.id && entry.title) + .map((entry) => ({ + videoId: entry.id!, + title: entry.title!, + durationSec: typeof entry.duration === 'number' ? entry.duration : parseDurationTextToSeconds(entry.duration_string ?? null), + durationText: entry.duration_string ?? null, + })), + }; + } + + private static spawnFfmpeg(streamUrl: string) { + if (!ffmpegPath) { + throw new Error('FFMPEG_NOT_FOUND'); + } + + const process = spawn(ffmpegPath, [ + '-user_agent', + this.FFMPEG_USER_AGENT, + '-headers', + 'Referer: https://www.youtube.com\r\nOrigin: https://www.youtube.com\r\n', + '-reconnect', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + '5', + '-i', + streamUrl, + '-vn', + '-loglevel', + 'error', + '-f', + 's16le', + '-ar', + '48000', + '-ac', + '2', + 'pipe:1', + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + process.stderr.on('data', (chunk) => { + const message = chunk.toString().trim(); + if (message) { + logger.debug(`[Music FFmpeg] ${message}`); + } + }); + + return process; + } + + private static cleanupProcess(session: GuildMusicSession) { + this.stopProgressUpdates(session); + + if (session.ffmpegProcess && !session.ffmpegProcess.killed) { + session.ffmpegProcess.kill(); + } + session.ffmpegProcess = null; + } + + private static async renderNowPlaying(session: GuildMusicSession) { + if (!session.nowPlaying) { + return; + } + + const embed = this.buildNowPlayingEmbed(session); + const components = [this.buildControlRow(session, false)]; + + if (session.controlMessage) { + await session.controlMessage.edit({ embeds: [embed], components }); + } else { + session.controlMessage = await this.safeSend(session.textChannel, { embeds: [embed], components }); + } + + this.startProgressUpdates(session); + } + + private static async renderIdleState(session: GuildMusicSession) { + this.stopProgressUpdates(session); + + if (!session.controlMessage) { + return; + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(t(session.locale, 'commands.music.playbackIdleTitle')) + .setDescription(t(session.locale, 'commands.music.playbackIdleBody')); + + await session.controlMessage.edit({ + embeds: [embed], + components: [this.buildControlRow(session, true)], + }); + } + + private static buildControlRow(session: GuildMusicSession, disabled: boolean) { + const paused = this.isPaused(session); + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`music_${paused ? 'resume' : 'pause'}_${session.guildId}`) + .setEmoji(paused ? '▶️' : '⏸️') + .setStyle(ButtonStyle.Success) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`music_skip_${session.guildId}`) + .setEmoji('⏭️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`music_stop_${session.guildId}`) + .setEmoji('⏹️') + .setStyle(ButtonStyle.Danger) + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`music_leave_${session.guildId}`) + .setEmoji('👋') + .setStyle(ButtonStyle.Primary) + .setDisabled(false), + ); + } + + private static buildFallbackControlRow(guildId: string) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`music_pause_${guildId}`) + .setEmoji('⏸️') + .setStyle(ButtonStyle.Success) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`music_skip_${guildId}`) + .setEmoji('⏭️') + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`music_stop_${guildId}`) + .setEmoji('⏹️') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`music_leave_${guildId}`) + .setEmoji('👋') + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + ); + } + + private static buildNowPlayingEmbed(session: GuildMusicSession) { + const track = session.nowPlaying!; + const durationSec = track.durationSec; + const elapsedSec = this.getElapsedSeconds(session); + const boundedElapsed = durationSec !== null ? Math.min(elapsedSec, durationSec) : elapsedSec; + const progressText = + durationSec !== null + ? `${formatDuration(boundedElapsed)} ${this.buildProgressBar(boundedElapsed, durationSec)} ${formatDuration(durationSec)}` + : `${formatDuration(boundedElapsed)} ${this.buildIndeterminateProgressBar(boundedElapsed)} ${t(session.locale, 'commands.music.unknownDuration')}`; + const nextTrack = session.queue[0] ?? null; + const statusKey = this.isPaused(session) ? 'commands.music.statusPaused' : 'commands.music.statusPlaying'; + + const embed = new EmbedBuilder() + .setColor(0xff5555) + .setTitle(t(session.locale, 'commands.music.playbackStartedTitle')) + .setDescription(`**${track.title}**`) + .addFields( + { + name: t(session.locale, 'commands.music.status'), + value: t(session.locale, statusKey), + inline: true, + }, + { + name: t(session.locale, 'commands.music.requestedBy'), + value: `<@${track.requestedByUserId}>`, + inline: true, + }, + { + name: t(session.locale, 'commands.music.duration'), + value: track.durationText, + inline: true, + }, + { + name: t(session.locale, 'commands.music.queueLength'), + value: String(session.queue.length), + inline: true, + }, + { + name: t(session.locale, 'commands.music.progress'), + value: progressText, + }, + { + name: t(session.locale, 'commands.music.source'), + value: track.sourceUrl, + }, + ); + + if (nextTrack) { + embed.addFields({ + name: t(session.locale, 'commands.music.nextTrack'), + value: this.formatTrackLine(nextTrack), + }); + } + + return embed; + } + + private static buildProgressBar(elapsedSec: number, durationSec: number) { + const width = 12; + const ratio = durationSec > 0 ? Math.min(1, elapsedSec / durationSec) : 0; + const filled = Math.max(1, Math.min(width, Math.round(ratio * width))); + return `${'■'.repeat(filled)}${'□'.repeat(width - filled)}`; + } + + private static buildIndeterminateProgressBar(elapsedSec: number) { + const width = 12; + const position = Math.abs(Math.floor(elapsedSec / 5)) % width; + return Array.from({ length: width }, (_, index) => (index === position ? '■' : '□')).join(''); + } + + private static startProgressUpdates(session: GuildMusicSession) { + this.stopProgressUpdates(session); + + if (!session.controlMessage || !session.nowPlaying) { + return; + } + + session.progressInterval = setInterval(() => { + if (!session.controlMessage || !session.nowPlaying) { + this.stopProgressUpdates(session); + return; + } + + void session.controlMessage.edit({ + embeds: [this.buildNowPlayingEmbed(session)], + components: [this.buildControlRow(session, false)], + }).catch((error: unknown) => { + logger.warn('Failed to update music progress message:', error); + this.stopProgressUpdates(session); + }); + }, 5_000); + } + + private static stopProgressUpdates(session: GuildMusicSession) { + if (session.progressInterval) { + clearInterval(session.progressInterval); + session.progressInterval = null; + } + } + + private static isPaused(session: GuildMusicSession) { + return session.player.state.status === AudioPlayerStatus.Paused || session.pausedStartedAt !== null; + } + + private static getElapsedSeconds(session: GuildMusicSession) { + if (!session.nowPlayingStartedAt) { + return 0; + } + + const activeUntil = session.pausedStartedAt ?? Date.now(); + const elapsedMs = Math.max(0, activeUntil - session.nowPlayingStartedAt - session.accumulatedPausedMs); + return Math.floor(elapsedMs / 1000); + } + + private static async refreshControlMessage(session: GuildMusicSession) { + if (!session.controlMessage) { + return; + } + + if (!session.nowPlaying) { + await this.renderIdleState(session); + return; + } + + await session.controlMessage.edit({ + embeds: [this.buildNowPlayingEmbed(session)], + components: [this.buildControlRow(session, false)], + }); + + if (this.isPaused(session)) { + this.stopProgressUpdates(session); + } else { + this.startProgressUpdates(session); + } + } + + private static formatTrackLine(track: MusicQueueItem) { + return `**${track.title}** \`${track.durationText}\` • <@${track.requestedByUserId}>`; + } + + private static async safeSend(channel: MusicTextChannel, payload: Record) { + try { + return await channel.send(payload); + } catch (error) { + logger.error('Failed to send music message:', error); + return null; + } + } +} diff --git a/tests/services/MusicService.test.ts b/tests/services/MusicService.test.ts new file mode 100644 index 0000000..576aaf7 --- /dev/null +++ b/tests/services/MusicService.test.ts @@ -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'); + }); +}); diff --git a/yarn.lock b/yarn.lock index e9c7535..70aa6bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -381,6 +381,25 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^2.0.0": + version: 2.11.0 + resolution: "@bufbuild/protobuf@npm:2.11.0" + checksum: 10c0/d54fffd414660b823999cc321d26bd6c5f18a6e75343fc7d2588bda5be540ec542b557ac1f03d6d4b6e9d3e5596b2016e58cda173cd1858c043f0e846ece453f + languageName: node + linkType: hard + +"@derhuerst/http-basic@npm:^8.2.0": + version: 8.2.4 + resolution: "@derhuerst/http-basic@npm:8.2.4" + dependencies: + caseless: "npm:^0.12.0" + concat-stream: "npm:^2.0.0" + http-response-object: "npm:^3.0.1" + parse-cache-control: "npm:^1.0.1" + checksum: 10c0/f500c53d8f587ce980f55638d9912ba03652b7483181b90ed41d74fb7eae85c6e05b2f739d97860413886ada9d58d169ec49208237f62c55bdd542f8003f8389 + languageName: node + linkType: hard + "@discordjs/builders@npm:^1.13.0": version: 1.14.0 resolution: "@discordjs/builders@npm:1.14.0" @@ -419,6 +438,35 @@ __metadata: languageName: node linkType: hard +"@discordjs/node-pre-gyp@npm:^0.4.5": + version: 0.4.5 + resolution: "@discordjs/node-pre-gyp@npm:0.4.5" + dependencies: + detect-libc: "npm:^2.0.0" + https-proxy-agent: "npm:^5.0.0" + make-dir: "npm:^3.1.0" + node-fetch: "npm:^2.6.7" + nopt: "npm:^5.0.0" + npmlog: "npm:^5.0.1" + rimraf: "npm:^3.0.2" + semver: "npm:^7.3.5" + tar: "npm:^6.1.11" + bin: + node-pre-gyp: bin/node-pre-gyp + checksum: 10c0/617e2d6efd00d56d2a276670e8592ee2d6de7b8149a4d8e38fea7b8effae676944f46be2867c6964a0bd7b4c9923e3cd0027f6ef29954848500de3efe867a94e + languageName: node + linkType: hard + +"@discordjs/opus@npm:^0.10.0": + version: 0.10.0 + resolution: "@discordjs/opus@npm:0.10.0" + dependencies: + "@discordjs/node-pre-gyp": "npm:^0.4.5" + node-addon-api: "npm:^8.1.0" + checksum: 10c0/d7cb5f95c4b9556b3dc6b32e5a5f589396230fcdce33e4aab98d8623bcc8895b0776116255ffb93c5dcca0fb98db3e39b6b70f1687dc2104e0a9f189b5e28362 + languageName: node + linkType: hard + "@discordjs/rest@npm:^2.5.1, @discordjs/rest@npm:^2.6.0": version: 2.6.1 resolution: "@discordjs/rest@npm:2.6.1" @@ -445,6 +493,20 @@ __metadata: languageName: node linkType: hard +"@discordjs/voice@npm:^0.19.2": + version: 0.19.2 + resolution: "@discordjs/voice@npm:0.19.2" + dependencies: + "@snazzah/davey": "npm:^0.1.9" + "@types/ws": "npm:^8.18.1" + discord-api-types: "npm:^0.38.41" + prism-media: "npm:^1.3.5" + tslib: "npm:^2.8.1" + ws: "npm:^8.19.0" + checksum: 10c0/e45c8d2ce7297a7b665044e900da32dea0232c060c3a66235c6e164252ad17c1471b42ba3d074d5d9e3ca61bea4636a6c00854c51fee91ab2676cbf2a993a367 + languageName: node + linkType: hard + "@discordjs/ws@npm:^1.2.3": version: 1.2.3 resolution: "@discordjs/ws@npm:1.2.3" @@ -1143,6 +1205,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.2": + version: 1.1.2 + resolution: "@napi-rs/wasm-runtime@npm:1.1.2" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/725c30ec9c480a8d0c1a6a4ce31dc6c830365d485e23ad560e143d1cb9db89a0c95fbb5b9d53c07121729817a3683db6f1ab65d7e4f38fa7482a11b15ef6c6fd + languageName: node + linkType: hard + "@npmcli/agent@npm:^4.0.0": version: 4.0.0 resolution: "@npmcli/agent@npm:4.0.0" @@ -1303,7 +1377,158 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.0": +"@snazzah/davey-android-arm-eabi@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-android-arm-eabi@npm:0.1.11" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@snazzah/davey-android-arm64@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-android-arm64@npm:0.1.11" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@snazzah/davey-darwin-arm64@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-darwin-arm64@npm:0.1.11" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@snazzah/davey-darwin-x64@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-darwin-x64@npm:0.1.11" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@snazzah/davey-freebsd-x64@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-freebsd-x64@npm:0.1.11" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@snazzah/davey-linux-arm-gnueabihf@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-linux-arm-gnueabihf@npm:0.1.11" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@snazzah/davey-linux-arm64-gnu@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-linux-arm64-gnu@npm:0.1.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@snazzah/davey-linux-arm64-musl@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-linux-arm64-musl@npm:0.1.11" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@snazzah/davey-linux-x64-gnu@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-linux-x64-gnu@npm:0.1.11" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@snazzah/davey-linux-x64-musl@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-linux-x64-musl@npm:0.1.11" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@snazzah/davey-wasm32-wasi@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-wasm32-wasi@npm:0.1.11" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.2" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@snazzah/davey-win32-arm64-msvc@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-win32-arm64-msvc@npm:0.1.11" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@snazzah/davey-win32-ia32-msvc@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-win32-ia32-msvc@npm:0.1.11" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@snazzah/davey-win32-x64-msvc@npm:0.1.11": + version: 0.1.11 + resolution: "@snazzah/davey-win32-x64-msvc@npm:0.1.11" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@snazzah/davey@npm:^0.1.9": + version: 0.1.11 + resolution: "@snazzah/davey@npm:0.1.11" + dependencies: + "@snazzah/davey-android-arm-eabi": "npm:0.1.11" + "@snazzah/davey-android-arm64": "npm:0.1.11" + "@snazzah/davey-darwin-arm64": "npm:0.1.11" + "@snazzah/davey-darwin-x64": "npm:0.1.11" + "@snazzah/davey-freebsd-x64": "npm:0.1.11" + "@snazzah/davey-linux-arm-gnueabihf": "npm:0.1.11" + "@snazzah/davey-linux-arm64-gnu": "npm:0.1.11" + "@snazzah/davey-linux-arm64-musl": "npm:0.1.11" + "@snazzah/davey-linux-x64-gnu": "npm:0.1.11" + "@snazzah/davey-linux-x64-musl": "npm:0.1.11" + "@snazzah/davey-wasm32-wasi": "npm:0.1.11" + "@snazzah/davey-win32-arm64-msvc": "npm:0.1.11" + "@snazzah/davey-win32-ia32-msvc": "npm:0.1.11" + "@snazzah/davey-win32-x64-msvc": "npm:0.1.11" + dependenciesMeta: + "@snazzah/davey-android-arm-eabi": + optional: true + "@snazzah/davey-android-arm64": + optional: true + "@snazzah/davey-darwin-arm64": + optional: true + "@snazzah/davey-darwin-x64": + optional: true + "@snazzah/davey-freebsd-x64": + optional: true + "@snazzah/davey-linux-arm-gnueabihf": + optional: true + "@snazzah/davey-linux-arm64-gnu": + optional: true + "@snazzah/davey-linux-arm64-musl": + optional: true + "@snazzah/davey-linux-x64-gnu": + optional: true + "@snazzah/davey-linux-x64-musl": + optional: true + "@snazzah/davey-wasm32-wasi": + optional: true + "@snazzah/davey-win32-arm64-msvc": + optional: true + "@snazzah/davey-win32-ia32-msvc": + optional: true + "@snazzah/davey-win32-x64-msvc": + optional: true + checksum: 10c0/f0608e2e799f49272e15b6143c49b15a0c10046bdcbbc389778fc9a716b7c9236222dabc09d105c0225bff77733537bc43959bb724e6c3cc4ffe4225af155b2f + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" dependencies: @@ -1418,6 +1643,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^10.0.3": + version: 10.17.60 + resolution: "@types/node@npm:10.17.60" + checksum: 10c0/0742294912a6e79786cdee9ed77cff6ee8ff007b55d8e21170fc3e5994ad3a8101fea741898091876f8dc32b0a5ae3d64537b7176799e92da56346028d2cbcd2 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -1425,7 +1657,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.10": +"@types/ws@npm:^8.18.1, @types/ws@npm:^8.5.10": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" dependencies: @@ -1734,6 +1966,13 @@ __metadata: languageName: node linkType: hard +"abbrev@npm:1": + version: 1.1.1 + resolution: "abbrev@npm:1.1.1" + checksum: 10c0/3f762677702acb24f65e813070e306c61fafe25d4b2583f9dfc935131f774863f3addd5741572ed576bd69cabe473c5af18e1e108b829cb7b6b4747884f726e6 + languageName: node + linkType: hard + "abbrev@npm:^4.0.0": version: 4.0.0 resolution: "abbrev@npm:4.0.0" @@ -1759,6 +1998,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.4 resolution: "agent-base@npm:7.1.4" @@ -1834,6 +2082,23 @@ __metadata: languageName: node linkType: hard +"aproba@npm:^1.0.3 || ^2.0.0": + version: 2.1.0 + resolution: "aproba@npm:2.1.0" + checksum: 10c0/ec8c1d351bac0717420c737eb062766fb63bde1552900e0f4fdad9eb064c3824fef23d1c416aa5f7a80f21ca682808e902d79b7c9ae756d342b5f1884f36932f + languageName: node + linkType: hard + +"are-we-there-yet@npm:^2.0.0": + version: 2.0.0 + resolution: "are-we-there-yet@npm:2.0.0" + dependencies: + delegates: "npm:^1.0.0" + readable-stream: "npm:^3.6.0" + checksum: 10c0/375f753c10329153c8d66dc95e8f8b6c7cc2aa66e05cb0960bd69092b10dae22900cacc7d653ad11d26b3ecbdbfe1e8bfb6ccf0265ba8077a7d979970f16b99c + languageName: node + linkType: hard + "argparse@npm:^1.0.7": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -2056,6 +2321,13 @@ __metadata: languageName: node linkType: hard +"caseless@npm:^0.12.0": + version: 0.12.0 + resolution: "caseless@npm:0.12.0" + checksum: 10c0/ccf64bcb6c0232cdc5b7bd91ddd06e23a4b541f138336d4725233ac538041fb2f29c2e86c3c4a7a61ef990b665348db23a047060b9414c3a6603e9fa61ad4626 + languageName: node + linkType: hard + "chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -2073,6 +2345,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6 + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -2142,6 +2421,15 @@ __metadata: languageName: node linkType: hard +"color-support@npm:^1.1.2": + version: 1.1.3 + resolution: "color-support@npm:1.1.3" + bin: + color-support: bin.js + checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -2149,6 +2437,25 @@ __metadata: languageName: node linkType: hard +"concat-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "concat-stream@npm:2.0.0" + dependencies: + buffer-from: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.0.2" + typedarray: "npm:^0.0.6" + checksum: 10c0/29565dd9198fe1d8cf57f6cc71527dbc6ad67e12e4ac9401feb389c53042b2dceedf47034cbe702dfc4fd8df3ae7e6bfeeebe732cc4fa2674e484c13f04c219a + languageName: node + linkType: hard + +"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": + version: 1.1.0 + resolution: "console-control-strings@npm:1.1.0" + checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50 + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -2205,6 +2512,13 @@ __metadata: languageName: node linkType: hard +"delegates@npm:^1.0.0": + version: 1.0.0 + resolution: "delegates@npm:1.0.0" + checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5 + languageName: node + linkType: hard + "denque@npm:^2.1.0": version: 2.1.0 resolution: "denque@npm:2.1.0" @@ -2212,6 +2526,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -2226,6 +2547,13 @@ __metadata: languageName: node linkType: hard +"discord-api-types@npm:^0.38.41": + version: 0.38.43 + resolution: "discord-api-types@npm:0.38.43" + checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0 + languageName: node + linkType: hard + "discord.js@npm:^14.25.1": version: 14.25.1 resolution: "discord.js@npm:14.25.1" @@ -2637,6 +2965,18 @@ __metadata: languageName: node linkType: hard +"ffmpeg-static@npm:^5.3.0": + version: 5.3.0 + resolution: "ffmpeg-static@npm:5.3.0" + dependencies: + "@derhuerst/http-basic": "npm:^8.2.0" + env-paths: "npm:^2.2.0" + https-proxy-agent: "npm:^5.0.0" + progress: "npm:^2.0.3" + checksum: 10c0/607fbd40661140e9de38ab2c51b2bd7a5b51da5c5c4ae8d9b6f9af059dec0cad23e1fa2f690d357ba18fe3218dc1f1f9d14acadc941e987d03dce75ae1244f39 + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -2693,6 +3033,15 @@ __metadata: languageName: node linkType: hard +"fs-minipass@npm:^2.0.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004 + languageName: node + linkType: hard + "fs-minipass@npm:^3.0.0": version: 3.0.3 resolution: "fs-minipass@npm:3.0.3" @@ -2728,6 +3077,23 @@ __metadata: languageName: node linkType: hard +"gauge@npm:^3.0.0": + version: 3.0.2 + resolution: "gauge@npm:3.0.2" + dependencies: + aproba: "npm:^1.0.3 || ^2.0.0" + color-support: "npm:^1.1.2" + console-control-strings: "npm:^1.0.0" + has-unicode: "npm:^2.0.1" + object-assign: "npm:^4.1.1" + signal-exit: "npm:^3.0.0" + string-width: "npm:^4.2.3" + strip-ansi: "npm:^6.0.1" + wide-align: "npm:^1.1.2" + checksum: 10c0/75230ccaf216471e31025c7d5fcea1629596ca20792de50c596eb18ffb14d8404f927cd55535aab2eeecd18d1e11bd6f23ec3c2e9878d2dda1dc74bccc34b913 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -2801,7 +3167,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.4": +"glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -2847,6 +3213,13 @@ __metadata: languageName: node linkType: hard +"has-unicode@npm:^2.0.1": + version: 2.0.1 + resolution: "has-unicode@npm:2.0.1" + checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -2871,6 +3244,25 @@ __metadata: languageName: node linkType: hard +"http-response-object@npm:^3.0.1": + version: 3.0.2 + resolution: "http-response-object@npm:3.0.2" + dependencies: + "@types/node": "npm:^10.0.3" + checksum: 10c0/f161db99184087798563cb14c48a67eebe9405668a5ed2341faf85d3079a2c00262431df8e0ccbe274dc6415b6729179f12b09f875d13ad33d83401e4b1ed22e + languageName: node + linkType: hard + +"https-proxy-agent@npm:^5.0.0": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -2940,7 +3332,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2": +"inherits@npm:2, inherits@npm:^2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -3609,6 +4001,8 @@ __metadata: version: 0.0.0-use.local resolution: "kord@workspace:." dependencies: + "@discordjs/opus": "npm:^0.10.0" + "@discordjs/voice": "npm:^0.19.2" "@prisma/client": "npm:6.4.1" "@types/jest": "npm:^30.0.0" "@types/node": "npm:^25.5.0" @@ -3617,13 +4011,16 @@ __metadata: discord.js: "npm:^14.25.1" dotenv: "npm:^17.3.1" eslint: "npm:^10.1.0" + ffmpeg-static: "npm:^5.3.0" ioredis: "npm:^5.10.1" jest: "npm:^30.3.0" prettier: "npm:^3.8.1" + prism-media: "npm:^1.3.5" prisma: "npm:6.4.1" ts-jest: "npm:^29.4.6" tsx: "npm:^4.21.0" typescript: "npm:^6.0.2" + youtubei.js: "npm:^17.0.1" languageName: unknown linkType: soft @@ -3734,6 +4131,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^3.1.0": + version: 3.1.0 + resolution: "make-dir@npm:3.1.0" + dependencies: + semver: "npm:^6.0.0" + checksum: 10c0/56aaafefc49c2dfef02c5c95f9b196c4eb6988040cf2c712185c7fe5c99b4091591a7fc4d4eafaaefa70ff763a26f6ab8c3ff60b9e75ea19876f49b18667ecaa + languageName: node + linkType: hard + "make-dir@npm:^4.0.0": version: 4.0.0 resolution: "make-dir@npm:4.0.0" @@ -3786,6 +4192,13 @@ __metadata: languageName: node linkType: hard +"meriyah@npm:^6.1.4": + version: 6.1.4 + resolution: "meriyah@npm:6.1.4" + checksum: 10c0/d792a12df453b9e86c2bb70e73adf41b2d0fa88125381143e4d339d309ddcbb7aeb108e286411dcb19be82539d29b881279c613ae03ce514b7c296c72e9a8d00 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -3887,6 +4300,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462 + languageName: node + linkType: hard + "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": version: 7.1.3 resolution: "minipass@npm:7.1.3" @@ -3894,6 +4314,16 @@ __metadata: languageName: node linkType: hard +"minizlib@npm:^2.1.1": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: "npm:^3.0.0" + yallist: "npm:^4.0.0" + checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78 + languageName: node + linkType: hard + "minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": version: 3.1.0 resolution: "minizlib@npm:3.1.0" @@ -3903,6 +4333,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^1.0.3": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -3940,6 +4379,29 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.1.0": + version: 8.7.0 + resolution: "node-addon-api@npm:8.7.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/31a03b00f6b0753ab08360952fdf80a1abb619dcf8125fa1ab07e3a414da050963440c3a86c77a0334c0be7a71acb5e242dc468b79201ee6151c7b943afe946d + languageName: node + linkType: hard + +"node-fetch@npm:^2.6.7": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 12.2.0 resolution: "node-gyp@npm:12.2.0" @@ -3974,6 +4436,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:^5.0.0": + version: 5.0.0 + resolution: "nopt@npm:5.0.0" + dependencies: + abbrev: "npm:1" + bin: + nopt: bin/nopt.js + checksum: 10c0/fc5c4f07155cb455bf5fc3dd149fac421c1a40fd83c6bfe83aa82b52f02c17c5e88301321318adaa27611c8a6811423d51d29deaceab5fa158b585a61a551061 + languageName: node + linkType: hard + "nopt@npm:^9.0.0": version: 9.0.0 resolution: "nopt@npm:9.0.0" @@ -4001,6 +4474,25 @@ __metadata: languageName: node linkType: hard +"npmlog@npm:^5.0.1": + version: 5.0.1 + resolution: "npmlog@npm:5.0.1" + dependencies: + are-we-there-yet: "npm:^2.0.0" + console-control-strings: "npm:^1.1.0" + gauge: "npm:^3.0.0" + set-blocking: "npm:^2.0.0" + checksum: 10c0/489ba519031013001135c463406f55491a17fc7da295c18a04937fe3a4d523fd65e88dd418a28b967ab743d913fdeba1e29838ce0ad8c75557057c481f7d49fa + languageName: node + linkType: hard + +"object-assign@npm:^4.1.1": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 + languageName: node + linkType: hard + "once@npm:^1.3.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -4090,6 +4582,13 @@ __metadata: languageName: node linkType: hard +"parse-cache-control@npm:^1.0.1": + version: 1.0.1 + resolution: "parse-cache-control@npm:1.0.1" + checksum: 10c0/330a0d9e3a22a7b0f6e8a973c0b9f51275642ee28544cd0d546420273946d555d20a5c7b49fca24d68d2e698bae0186f0f41f48d62133d3153c32454db05f2df + languageName: node + linkType: hard + "parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" @@ -4207,6 +4706,27 @@ __metadata: languageName: node linkType: hard +"prism-media@npm:^1.3.5": + version: 1.3.5 + resolution: "prism-media@npm:1.3.5" + peerDependencies: + "@discordjs/opus": ">=0.8.0 <1.0.0" + ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0 + node-opus: ^0.3.3 + opusscript: ^0.0.8 + peerDependenciesMeta: + "@discordjs/opus": + optional: true + ffmpeg-static: + optional: true + node-opus: + optional: true + opusscript: + optional: true + checksum: 10c0/3bd9f3b246c8ac7aa744d87d502f65280f5c5635555a08d8ff548da99f29ee2bf92c4696dab1e22c2e773f71f95a24ec4f06d82dea39f07c1d7b90956c56d173 + languageName: node + linkType: hard + "prisma@npm:6.4.1": version: 6.4.1 resolution: "prisma@npm:6.4.1" @@ -4236,6 +4756,13 @@ __metadata: languageName: node linkType: hard +"progress@npm:^2.0.3": + version: 2.0.3 + resolution: "progress@npm:2.0.3" + checksum: 10c0/1697e07cb1068055dbe9fe858d242368ff5d2073639e652b75a7eb1f2a1a8d4afd404d719de23c7b48481a6aa0040686310e2dac2f53d776daa2176d3f96369c + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -4257,6 +4784,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.0.2, readable-stream@npm:^3.6.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + "redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": version: 1.2.0 resolution: "redis-errors@npm:1.2.0" @@ -4303,6 +4841,24 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: "npm:^7.1.3" + bin: + rimraf: bin.js + checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 + languageName: node + linkType: hard + +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -4310,7 +4866,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.0.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -4328,6 +4884,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -4344,7 +4907,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.3": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 @@ -4452,7 +5015,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -4474,6 +5037,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -4540,6 +5112,20 @@ __metadata: languageName: node linkType: hard +"tar@npm:^6.1.11": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537 + languageName: node + linkType: hard + "tar@npm:^7.5.4": version: 7.5.13 resolution: "tar@npm:7.5.13" @@ -4581,6 +5167,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "ts-api-utils@npm:^2.4.0": version: 2.5.0 resolution: "ts-api-utils@npm:2.5.0" @@ -4637,7 +5230,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3": +"tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -4690,6 +5283,13 @@ __metadata: languageName: node linkType: hard +"typedarray@npm:^0.0.6": + version: 0.0.6 + resolution: "typedarray@npm:0.0.6" + checksum: 10c0/6005cb31df50eef8b1f3c780eb71a17925f3038a100d82f9406ac2ad1de5eb59f8e6decbdc145b3a1f8e5836e17b0c0002fb698b9fe2516b8f9f9ff602d36412 + languageName: node + linkType: hard + "typescript@npm:^6.0.2": version: 6.0.2 resolution: "typescript@npm:6.0.2" @@ -4830,6 +5430,13 @@ __metadata: languageName: node linkType: hard +"util-deprecate@npm:^1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 + languageName: node + linkType: hard + "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -4850,6 +5457,23 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -4872,6 +5496,15 @@ __metadata: languageName: node linkType: hard +"wide-align@npm:^1.1.2": + version: 1.1.5 + resolution: "wide-align@npm:1.1.5" + dependencies: + string-width: "npm:^1.0.2 || 2 || 3 || 4" + checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -4925,7 +5558,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.17.0": +"ws@npm:^8.17.0, ws@npm:^8.19.0": version: 8.20.0 resolution: "ws@npm:8.20.0" peerDependencies: @@ -4996,3 +5629,13 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"youtubei.js@npm:^17.0.1": + version: 17.0.1 + resolution: "youtubei.js@npm:17.0.1" + dependencies: + "@bufbuild/protobuf": "npm:^2.0.0" + meriyah: "npm:^6.1.4" + checksum: 10c0/18b188d9a706feca6b6a23582c00b3fdb955a1be823854b9c4b0312ccf7c3539db28a78184fb4927289388e363d5594f39df63e7253874b39317077309088d98 + languageName: node + linkType: hard