Merge branch 'main' into delete-redis

# Conflicts:
#	package.json
#	yarn.lock
This commit is contained in:
mineseo-kim 2026-03-31 09:29:49 +09:00
commit 1f91bfb9bf
15 changed files with 3212 additions and 10 deletions

View File

@ -0,0 +1,314 @@
# Kord - YouTube 음악 재생 기능 기획안 (YouTube Music Playback)
## 체인지로그 (Changelog)
- **2026-03-30**: 최초 기획안 작성
---
## 1. 개요
YouTube 음악 재생 기능은 사용자가 텍스트 기반 명령으로 음악을 검색하거나 YouTube 링크를 입력하면,
현재 참여 중인 음성 채팅방에 봇이 입장하여 재생 목록(플레이리스트)을 관리하고 오디오를 재생하는 기능입니다.
이 기능은 서버 유틸리티를 넘어서 실시간 상호작용 기능으로 확장되는 첫 사례이며,
음성 연결, 큐 관리, UI 컨트롤, 외부 미디어 소스 처리까지 포함하는 비교적 큰 범위의 기능입니다.
### 목표
- 문자열 검색으로 YouTube 영상을 찾아 재생 목록에 추가할 수 있어야 함
- YouTube 링크를 입력해 직접 재생 목록에 추가할 수 있어야 함
- 현재 재생 목록을 조회할 수 있어야 함
- 인덱스 기반으로 재생 목록 항목을 삭제할 수 있어야 함
- 재생 중지 / 스킵 / 일시정지 / 재개 등 기본 컨트롤을 버튼 또는 이모지 기반 UI로 제공해야 함
- 음악 추가 요청 시, 요청자가 있는 음성 채팅방에 봇이 자동 입장하고 재생을 시작해야 함
- 마지막에 봇을 음성 채팅방에서 내보내는 기능이 있어야 함
---
## 2. 지원 범위 (MVP)
### 포함
- `/music add query:<문자열>`
- `/music add url:<YouTube 링크>`
- `/music queue`
- `/music remove index:<번호>`
- `/music skip`
- `/music stop`
- `/music leave`
- 재생 컨트롤 메시지 (버튼 또는 이모지 라벨 기반)
- 음성 채널 자동 입장 및 큐 기반 연속 재생
### 제외 (초기 범위 외)
- Spotify, SoundCloud 등 타 플랫폼 연동
- 반복 재생 / 셔플 / 볼륨 조절
- 길드별 DJ 역할 분리
- 재생 이력 저장
- 노래 가사 표시
---
## 3. 사용자 시나리오
### 시나리오 A: 검색어로 곡 추가
1. 사용자가 음성 채널에 입장한 상태에서 `/music add query:아이유 좋은날` 실행
2. 봇이 검색 결과 1위를 선택하거나 선택 UI를 제공
3. 재생 목록에 곡을 추가
4. 봇이 음성 채널에 자동 입장
5. 현재 재생 중이 없다면 즉시 재생 시작
### 시나리오 B: 링크로 곡 추가
1. 사용자가 `/music add url:https://www.youtube.com/watch?v=...` 실행
2. 봇이 링크 메타데이터를 파싱
3. 재생 목록에 항목 추가
4. 이미 재생 중이면 큐 뒤에 대기
### 시나리오 C: 큐 조회 및 삭제
1. 사용자가 `/music queue` 실행
2. 봇이 현재 재생곡과 대기열을 인덱스와 함께 Embed로 표시
3. 사용자가 `/music remove index:3` 실행
4. 3번 항목이 큐에서 제거됨
### 시나리오 D: 컨트롤 UI 사용
1. 재생 시작 시 봇이 "지금 재생 중" 메시지를 게시
2. 메시지에는 ⏸️, ▶️, ⏭️, ⏹️ 같은 버튼이 포함됨
3. 사용자가 버튼을 눌러 재생 상태를 제어
---
## 4. 명령 구조 제안
### `/music add`
입력 방식:
- `query`: YouTube 검색 문자열
- `url`: YouTube 링크
규칙:
- 두 옵션 중 하나만 입력
- 사용자는 반드시 음성 채널에 있어야 함
### `/music queue`
출력:
- 현재 재생 중 항목
- 대기열 목록
- 각 곡의 인덱스, 제목, 길이, 요청자
### `/music remove`
입력:
- `index`: 큐에서 제거할 번호
### `/music skip`
- 현재 곡을 스킵하고 다음 곡 재생
### `/music stop`
- 재생 중지
- 현재 곡 정지 및 대기열 비움 여부는 정책 선택 필요
### `/music leave`
- 봇을 음성 채널에서 퇴장시킴
- 기본 정책은 큐도 함께 정리
---
## 5. 재생 컨트롤 UI
### 컨트롤 메시지 구성
재생 시작 시 텍스트 채널에 Embed + 버튼 메시지 생성
권장 버튼:
- `⏸️` 일시정지
- `▶️` 재개
- `⏭️` 스킵
- `⏹️` 정지
- `📜` 큐 보기
### 정책
- 한 길드당 활성 컨트롤 메시지 1개 유지
- 곡이 바뀔 때 동일 메시지를 업데이트하거나 새 메시지를 생성할지 결정 필요
- MVP에서는 새 메시지 생성보다 기존 메시지 업데이트가 로그 노이즈를 줄이는 데 유리
---
## 6. 큐 및 재생 상태 설계
### 핵심 개념
- 재생 상태는 길드 단위로 분리
- 각 길드는 하나의 음성 연결과 하나의 큐를 가짐
- 큐는 메모리 기반으로 시작하고, 필요 시 DB 영속화 확장 가능
### 추천 구조
```ts
interface MusicQueueItem {
id: string;
title: string;
url: string;
durationSec?: number;
requestedByUserId: string;
}
interface GuildMusicSession {
guildId: string;
voiceChannelId: string;
textChannelId: string;
nowPlaying: MusicQueueItem | null;
queue: MusicQueueItem[];
paused: boolean;
controlMessageId?: string;
}
```
### 초기 전략
- 메모리 기반 세션 관리
- 봇 재시작 시 큐는 초기화됨
- 안정화 이후 DB 저장 여부 검토
---
## 7. 음성 입장 및 재생 흐름
### 자동 입장
- `/music add` 실행 시 사용자의 음성 채널을 확인
- 봇이 해당 채널에 없으면 자동 입장
- 이미 다른 채널에 있으면 정책 필요
### 기본 정책 제안
- 같은 길드 내에서 봇이 이미 재생 중이면 다른 채널 요청은 거부
- "현재 다른 음성 채널에서 사용 중" 메시지 제공
### 자동 퇴장
- `/music leave`로 수동 퇴장 가능
- 추가 정책 후보:
- 큐가 끝나고 1분 뒤 자동 퇴장
- 음성 채널에 봇만 남으면 자동 퇴장
---
## 8. YouTube 검색 및 스트리밍 전략
> [!IMPORTANT]
> YouTube 관련 기능은 라이브러리 안정성, 차단 이슈, 서비스 정책을 반드시 검토해야 합니다.
### 검색
가능한 접근:
- YouTube 공식 API 사용
- 서드파티 검색 라이브러리 사용
### 오디오 스트리밍
가능한 접근:
- `@discordjs/voice` 기반 음성 재생
- YouTube 스트림 추출 라이브러리 사용
### 기술 리스크
- YouTube 구조 변경 시 스트림 추출 라이브러리 고장 가능
- 지역 제한 / 연령 제한 / 라이브 영상 처리 문제
- 긴 재생 목록에서 메모리 및 연결 안정성 문제
---
## 9. 권한 및 운영 정책
### 봇 권한
- `Connect`
- `Speak`
- `View Channel`
- 텍스트 채널의 `Send Messages`, `Embed Links`
### 사용자 권한
- 기본적으로 일반 사용자도 곡 추가 가능
- `skip`, `stop`, `leave`, `remove`는 다음 중 하나로 제한 가능
- 관리자 전용
- 요청자 또는 관리자
- 같은 음성 채널 참여자 전원 허용
### 추천 MVP 정책
- `add`, `queue`: 같은 음성 채널 참여자 누구나 가능
- `skip`, `stop`, `remove`, `leave`: 관리자 또는 같은 음성 채널 참여자 허용
---
## 10. 에러 처리
필수 안내 케이스:
- 사용자가 음성 채널에 없음
- 유효하지 않은 YouTube 링크
- 검색 결과 없음
- 재생 목록 인덱스 범위 오류
- 봇 음성 권한 부족
- 스트림 로드 실패
에러 메시지는 기존 Error Guidance 체계와 연결하는 것이 좋습니다.
---
## 11. 구현 단계 (Phased Implementation)
| 단계 | 내용 |
|------|------|
| **Phase 1** | `@discordjs/voice` 기반 음성 연결, 메모리 큐, `/music add`, `/music queue`, `/music skip`, `/music leave` |
| **Phase 2** | 링크 기반 추가 + 검색 기반 추가 분리, `/music remove`, `/music stop` |
| **Phase 3** | 컨트롤 메시지(⏸️ ▶️ ⏭️ ⏹️) 및 상호작용 처리 |
| **Phase 4** | 자동 퇴장, 권한 정책 세분화, 예외 처리 고도화 |
| **Phase 5** | 반복 재생, 셔플, DJ 역할, 재생 이력 등 확장 기능 |
---
## 12. 검증 계획
### 수동 테스트
1. 음성 채널 입장 후 검색어로 곡 추가
2. 링크로 곡 추가
3. 큐 조회 및 인덱스 삭제
4. 스킵 / 정지 / 퇴장 동작 확인
5. 곡 종료 후 다음 곡 자동 재생 확인
6. 권한 부족 환경에서 적절한 에러 표시 확인
### 자동 테스트
- 큐 삽입 / 삭제 / 스킵 로직 단위 테스트
- 길드별 세션 분리 테스트
- 인덱스 유효성 검사 테스트
- 컨트롤 인터랙션 핸들러 테스트
---
## 13. 관련 문서
| 문서 | 링크 |
|------|------|
| 기능 로드맵 | [`Feature_Roadmap.md`](./Feature_Roadmap.md) |
| 임시 음성 채널 기획 | [`Temp_Voice_Channel_Plan.md`](./Temp_Voice_Channel_Plan.md) |
| 에러 안내 기획 | [`Error_Guidance_Plan.md`](./Error_Guidance_Plan.md) |

View File

@ -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 응답 변경 시 보완이 필요할 수 있다.

View File

@ -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` 대상이 아니며, 대기열 곡만 삭제된다.

View File

@ -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.

242
Docs/database-schema.md Normal file
View File

@ -0,0 +1,242 @@
# 데이터베이스 테이블 구조
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
## 개요
| 항목 | 값 |
|------|-----|
| DB | PostgreSQL |
| ORM | Prisma (`prisma-client-js`) |
| 연결 | `DATABASE_URL` 환경 변수 |
## 열거형 (Enum)
### `SubscriptionTier`
구독 단계.
| 값 | 설명 |
|----|------|
| `FREE` | 기본 |
| `STANDARD` | 스탠다드 |
| `PRO` | 프로 |
| `PREMIUM` | 프리미엄 |
### `DeleteCondition`
임시 음성 채널 삭제 조건.
| 값 | 설명 |
|----|------|
| `OWNER_LEAVE` | 소유자 퇴장 시 |
| `EMPTY` | 비었을 때 (기본) |
### `EventStatus`
길드 이벤트 상태.
| 값 | 설명 |
|----|------|
| `SCHEDULED` | 예정 (기본) |
| `CANCELLED` | 취소 |
| `COMPLETED` | 완료 |
---
## 테이블 목록
### `GuildConfig`
디스코드 길드별 봇 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `prefix` | `String` | 기본 `!` |
| `mimicEnabled` | `Boolean` | 기본 `false` |
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
| `locale` | `String?` | nullable |
| `createdAt` | `DateTime` | 생성 시각 |
| `updatedAt` | `DateTime` | 자동 갱신 |
---
### `InviteRole`
길드·초대 코드별 역할 매핑.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `inviteCode` | `String` | |
| `roleId` | `String` | |
| `createdAt` | `DateTime` | |
**유니크:** `(guildId, inviteCode)`
---
### `UserSubscription`
사용자 구독 정보.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `tier` | `SubscriptionTier` | 기본 `FREE` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**관계:** `GuildOwnership[]` (1:N)
---
### `GuildOwnership`
구독 사용자가 소유하는 길드.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
| `createdAt` | `DateTime` | |
**인덱스:** `ownerId`
---
### `VoiceGenerator`
음성 채널 생성기(부모 채널) 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `categoryId` | `String?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `guildId`
---
### `TempVoiceChannel`
생성된 임시 음성 채널.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `ownerId` | `String` | |
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
| `createdAt` | `DateTime` | |
**인덱스:** `guildId`, `ownerId`
---
### `UserVoiceProfile`
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | 복합 PK |
| `guildId` | `String` | 복합 PK |
| `customName` | `String?` | |
| `userLimit` | `Int?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**PK:** `(userId, guildId)`
---
### `UserLocale`
사용자별 로케일.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `locale` | `String` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `AuditChannel`
감사 로그 전달 채널·비활성 카테고리.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `channelId` | `String` | |
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `VoiceGuildConfig`
길드 단위 음성(임시 채널) 기본 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
| `defaultUserLimit` | `Int` | 기본 `0` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `GuildEvent`
길드 일정/이벤트.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `title` | `String` | |
| `description` | `String?` | |
| `startsAt` | `DateTime` | |
| `timezone` | `String` | 기본 `Asia/Seoul` |
| `status` | `EventStatus` | 기본 `SCHEDULED` |
| `announcementChannelId` | `String?` | |
| `createdByUserId` | `String` | |
| `reminderEnabled` | `Boolean` | 기본 `true` |
| `reminderOffsets` | `Int[]` | 기본 `[]` |
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
| `remindedOneHour` | `Boolean` | 기본 `false` |
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
| `startedAnnounced` | `Boolean` | 기본 `false` |
| `announcedAt` | `DateTime?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
---
## 관계 요약
```mermaid
erDiagram
UserSubscription ||--o{ GuildOwnership : "userId"
```
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
## 스키마 변경 시
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.

View File

@ -23,6 +23,7 @@
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) - [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) - [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md) - [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
- [YouTube 鞚岇晠 鞛<>儩 旮半姤 旮绊殟鞎<E6AE9F> (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
## 아키텍처 및 정책 결정 (Decisions) ## 아키텍처 및 정책 결정 (Decisions)
@ -53,3 +54,6 @@
- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md) - [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md) - [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md) - [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
- [2026-03-30: YouTube 澜厩 犁积 Phase 1 备泅 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
- [2026-03-31: YouTube 澜厩 犁积 Phase 2 备泅 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
- [2026-03-31: YouTube 澜厩 犁积 Phase 3 备泅 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)

View File

@ -2,9 +2,15 @@
"name": "kord", "name": "kord",
"packageManager": "yarn@4.9.1", "packageManager": "yarn@4.9.1",
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2",
"@prisma/client": "6.4.1", "@prisma/client": "6.4.1",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.3.1" "dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"ioredis": "^5.10.1",
"prism-media": "^1.3.5",
"youtubei.js": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",

431
src/commands/music.ts Normal file
View File

@ -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,
});
}
},
};

View File

@ -8,6 +8,7 @@ import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
import { t } from '../i18n'; import { t } from '../i18n';
import { getInteractionLocale } from '../i18n/localeHelper'; import { getInteractionLocale } from '../i18n/localeHelper';
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler'; import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
import { MusicService } from '../services/MusicService';
export default { export default {
name: Events.InteractionCreate, name: Events.InteractionCreate,
@ -22,6 +23,12 @@ export default {
await command.execute(interaction, locale); await command.execute(interaction, locale);
}, locale); }, locale);
} }
else if (interaction.isButton() && interaction.customId.startsWith('music_')) {
const locale = await getInteractionLocale(interaction);
await withErrorHandler(interaction, async () => {
await MusicService.handleControlInteraction(interaction, locale);
}, locale);
}
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) { else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
const locale = await getInteractionLocale(interaction); const locale = await getInteractionLocale(interaction);
await withErrorHandler(interaction, async () => { await withErrorHandler(interaction, async () => {

View File

@ -191,6 +191,73 @@ export const en: TranslationSchema = {
status: 'Status', status: 'Status',
}, },
}, },
music: {
description: 'Play YouTube audio in voice channels.',
addDescription: 'Search YouTube or add a video URL to the queue.',
queueDescription: 'Show the current music queue.',
removeDescription: 'Remove a track from the upcoming queue.',
pauseDescription: 'Pause the currently playing track.',
resumeDescription: 'Resume the paused track.',
skipDescription: 'Skip the currently playing track.',
stopDescription: 'Stop playback and clear the queue.',
leaveDescription: 'Disconnect the bot from the voice channel.',
queryDescription: 'Search query for YouTube',
urlDescription: 'YouTube video URL',
indexDescription: 'Queue index to remove',
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
notInVoice: 'You must be in a voice channel to use music commands.',
notInVoiceResolution: 'Join a voice channel first, then try again.',
differentVoiceChannel: 'Music is already being used in another voice channel.',
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
noSearchResults: 'No YouTube results were found for that query.',
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
invalidUrl: 'The provided YouTube URL is invalid.',
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
noActiveSession: 'There is no active music session in this server.',
noActiveSessionResolution: 'Add a track first to start playback.',
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
queueTitle: 'Music Queue',
queueEmpty: 'The music queue is currently empty.',
queueNowPlaying: 'Now Playing',
queueUpcoming: 'Up Next',
queueMoreItems: '...and **{{count}}** more track(s).',
queueRemoved: 'Removed **{{title}}** from the queue.',
queueRemoveOutOfRange: 'That queue index does not exist.',
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
pauseSuccess: 'Paused the current track.',
resumeSuccess: 'Resumed playback.',
skipSuccess: 'Skipped the current track.',
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
stopSuccess: 'Stopped playback and cleared the queue.',
playbackStartedTitle: 'Now Playing',
playbackIdleTitle: 'Queue Finished',
playbackIdleBody: 'There are no more tracks in the queue.',
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
streamUnavailable: 'Could not load a playable audio stream for this video.',
streamUnavailableResolution: 'Try another video or add the track again later.',
requestedBy: 'Requested by',
duration: 'Duration',
progress: 'Progress',
source: 'Source',
status: 'Status',
queueLength: 'Queue Length',
nextTrack: 'Next Track',
statusPlaying: 'Playing',
statusPaused: 'Paused',
unknownDuration: 'Unknown',
buttons: {
pause: '⏸ Pause',
resume: '▶ Resume',
skip: '⏭ Skip',
stop: '⏹ Stop',
leave: '👋 Leave',
},
},
permissionAudit: { permissionAudit: {
title: 'Bot Permission Audit Report', title: 'Bot Permission Audit Report',
channel: 'Channel', channel: 'Channel',

View File

@ -191,6 +191,73 @@ export const ko: TranslationSchema = {
status: '상태', status: '상태',
}, },
}, },
music: {
description: 'Play YouTube audio in voice channels.',
addDescription: 'Search YouTube or add a video URL to the queue.',
queueDescription: 'Show the current music queue.',
removeDescription: 'Remove a track from the upcoming queue.',
pauseDescription: 'Pause the currently playing track.',
resumeDescription: 'Resume the paused track.',
skipDescription: 'Skip the currently playing track.',
stopDescription: 'Stop playback and clear the queue.',
leaveDescription: 'Disconnect the bot from the voice channel.',
queryDescription: 'Search query for YouTube',
urlDescription: 'YouTube video URL',
indexDescription: 'Queue index to remove',
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
notInVoice: 'You must be in a voice channel to use music commands.',
notInVoiceResolution: 'Join a voice channel first, then try again.',
differentVoiceChannel: 'Music is already being used in another voice channel.',
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
noSearchResults: 'No YouTube results were found for that query.',
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
invalidUrl: 'The provided YouTube URL is invalid.',
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
noActiveSession: 'There is no active music session in this server.',
noActiveSessionResolution: 'Add a track first to start playback.',
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
queueTitle: 'Music Queue',
queueEmpty: 'The music queue is currently empty.',
queueNowPlaying: 'Now Playing',
queueUpcoming: 'Up Next',
queueMoreItems: '...and **{{count}}** more track(s).',
queueRemoved: 'Removed **{{title}}** from the queue.',
queueRemoveOutOfRange: 'That queue index does not exist.',
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
pauseSuccess: 'Paused the current track.',
resumeSuccess: 'Resumed playback.',
skipSuccess: 'Skipped the current track.',
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
stopSuccess: 'Stopped playback and cleared the queue.',
playbackStartedTitle: 'Now Playing',
playbackIdleTitle: 'Queue Finished',
playbackIdleBody: 'There are no more tracks in the queue.',
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
streamUnavailable: 'Could not load a playable audio stream for this video.',
streamUnavailableResolution: 'Try another video or add the track again later.',
requestedBy: 'Requested by',
duration: 'Duration',
progress: 'Progress',
source: 'Source',
status: 'Status',
queueLength: 'Queue Length',
nextTrack: 'Next Track',
statusPlaying: 'Playing',
statusPaused: 'Paused',
unknownDuration: 'Unknown',
buttons: {
pause: 'Pause',
resume: 'Resume',
skip: 'Skip',
stop: 'Stop',
leave: 'Leave',
},
},
permissionAudit: { permissionAudit: {
title: '봇 권한 진단 보고서', title: '봇 권한 진단 보고서',
channel: '채널', channel: '채널',

View File

@ -145,6 +145,73 @@ export interface TranslationSchema {
status: string; status: string;
}; };
}; };
music: {
description: string;
addDescription: string;
queueDescription: string;
removeDescription: string;
pauseDescription: string;
resumeDescription: string;
skipDescription: string;
stopDescription: string;
leaveDescription: string;
queryDescription: string;
urlDescription: string;
indexDescription: string;
addMutuallyExclusive: string;
addMutuallyExclusiveResolution: string;
notInVoice: string;
notInVoiceResolution: string;
differentVoiceChannel: string;
differentVoiceChannelResolution: string;
noSearchResults: string;
noSearchResultsResolution: string;
invalidUrl: string;
invalidUrlResolution: string;
noActiveSession: string;
noActiveSessionResolution: string;
queueAddedNowPlaying: string;
queueAddedLater: string;
playlistAddedNowPlaying: string;
playlistAddedLater: string;
queueTitle: string;
queueEmpty: string;
queueNowPlaying: string;
queueUpcoming: string;
queueMoreItems: string;
queueRemoved: string;
queueRemoveOutOfRange: string;
queueRemoveOutOfRangeResolution: string;
pauseSuccess: string;
resumeSuccess: string;
skipSuccess: string;
leaveSuccess: string;
stopSuccess: string;
playbackStartedTitle: string;
playbackIdleTitle: string;
playbackIdleBody: string;
playbackFailed: string;
playbackFailedResolution: string;
streamUnavailable: string;
streamUnavailableResolution: string;
requestedBy: string;
duration: string;
progress: string;
source: string;
status: string;
queueLength: string;
nextTrack: string;
statusPlaying: string;
statusPaused: string;
unknownDuration: string;
buttons: {
pause: string;
resume: string;
skip: string;
stop: string;
leave: string;
};
};
permissionAudit: { permissionAudit: {
title: string; title: string;
channel: string; channel: string;

1039
src/services/MusicService.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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');
});
});

737
yarn.lock

File diff suppressed because it is too large Load Diff