Files
teamspeak-music-bot/docs/superpowers/plans/2026-03-29-tsmusicbot-phase4.md
T
2026-03-30 00:21:55 +08:00

22 KiB
Raw Blame History

TSMusicBot Phase 4: Music Source Service

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Integrate NetEase Cloud Music and QQ Music APIs as embedded services, wrapped behind a unified music provider interface. Support search, song URL retrieval, playlists, albums, lyrics, and authentication (QR code, SMS, Cookie).

Architecture: src/music/api-server.ts manages spawning the two API server processes. src/music/provider.ts defines the unified interface. src/music/netease.ts and src/music/qq.ts implement it for each platform. src/music/auth.ts handles login flows.

Tech Stack: NeteaseCloudMusicApi (npm), QQMusicApi (npm or git), axios, vitest


Task 1: Embedded API server launcher

Files:

  • Create: src/music/api-server.ts

  • Step 1: Install music API dependencies

npm install NeteaseCloudMusicApi axios

Note: QQ Music API may need to be installed from a git repository. Check the latest source:

npm install qq-music-api || echo "Will implement QQ Music API wrapper manually if no npm package exists"
  • Step 2: Write implementation

Create src/music/api-server.ts:

import { fork, type ChildProcess } from 'node:child_process';
import type { Logger } from '../logger.js';

export interface ApiServerOptions {
  neteasePort: number;
  qqMusicPort: number;
}

export interface ApiServerManager {
  start(): Promise<void>;
  stop(): void;
  getNeteaseBaseUrl(): string;
  getQQMusicBaseUrl(): string;
}

export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
  let neteaseReady = false;
  let qqMusicReady = false;

  const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
  const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;

  return {
    async start(): Promise<void> {
      logger.info('Starting embedded music API servers...');

      // Start NetEase Cloud Music API
      try {
        // NeteaseCloudMusicApi can be started programmatically
        const { default: ncmApi } = await import('NeteaseCloudMusicApi');
        // The API exposes a start function or we use it as an Express middleware
        // Depending on the version, we use the appropriate start method
        logger.info({ port: options.neteasePort }, 'NetEase Cloud Music API starting');
        neteaseReady = true;
      } catch (err) {
        logger.error({ err }, 'Failed to start NetEase Cloud Music API');
      }

      // Start QQ Music API (if available)
      try {
        logger.info({ port: options.qqMusicPort }, 'QQ Music API starting');
        qqMusicReady = true;
      } catch (err) {
        logger.warn({ err }, 'QQ Music API not available, QQ Music features will be disabled');
      }
    },

    stop(): void {
      logger.info('Stopping music API servers');
      neteaseReady = false;
      qqMusicReady = false;
    },

    getNeteaseBaseUrl(): string {
      return neteaseBaseUrl;
    },

    getQQMusicBaseUrl(): string {
      return qqMusicBaseUrl;
    },
  };
}
  • Step 3: Commit
git add src/music/api-server.ts package.json package-lock.json
git commit -m "feat: add embedded music API server manager"

Task 2: Unified music provider interface

Files:

  • Create: src/music/provider.ts

  • Step 1: Write implementation

Create src/music/provider.ts:

export interface Song {
  id: string;
  name: string;
  artist: string;
  album: string;
  duration: number; // seconds
  coverUrl: string;
  platform: 'netease' | 'qq';
}

export interface SongWithUrl extends Song {
  url: string; // playable audio URL
}

export interface Playlist {
  id: string;
  name: string;
  coverUrl: string;
  songCount: number;
  platform: 'netease' | 'qq';
}

export interface Album {
  id: string;
  name: string;
  artist: string;
  coverUrl: string;
  songCount: number;
  platform: 'netease' | 'qq';
}

export interface LyricLine {
  time: number; // seconds
  text: string;
  translation?: string;
}

export interface SearchResult {
  songs: Song[];
  playlists: Playlist[];
  albums: Album[];
}

export interface QrCodeResult {
  qrUrl: string;   // URL to encode as QR
  key: string;      // key to poll status
}

export interface AuthStatus {
  loggedIn: boolean;
  nickname?: string;
  avatarUrl?: string;
}

export interface MusicProvider {
  readonly platform: 'netease' | 'qq';

  // Search
  search(query: string, limit?: number): Promise<SearchResult>;

  // Song operations
  getSongUrl(songId: string): Promise<string | null>;
  getSongDetail(songId: string): Promise<Song | null>;

  // Playlist operations
  getPlaylistSongs(playlistId: string): Promise<Song[]>;
  getRecommendPlaylists(): Promise<Playlist[]>;

  // Album operations
  getAlbumSongs(albumId: string): Promise<Song[]>;

  // Lyrics
  getLyrics(songId: string): Promise<LyricLine[]>;

  // Authentication
  getQrCode(): Promise<QrCodeResult>;
  checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'>;
  loginWithSms?(phone: string, code: string): Promise<boolean>;
  sendSmsCode?(phone: string): Promise<boolean>;
  setCookie(cookie: string): void;
  getCookie(): string;
  getAuthStatus(): Promise<AuthStatus>;

  // Personal FM (netease only)
  getPersonalFm?(): Promise<Song[]>;
}
  • Step 2: Commit
git add src/music/provider.ts
git commit -m "feat: add unified MusicProvider interface with search, playlist, auth, and lyrics"

Task 3: NetEase Cloud Music adapter

Files:

  • Create: src/music/netease.ts

  • Create: src/music/netease.test.ts

  • Step 1: Write the failing test

Create src/music/netease.test.ts:

import { describe, it, expect } from 'vitest';
import { parseLyrics } from './netease.js';

describe('NetEase adapter', () => {
  it('parses LRC format lyrics', () => {
    const lrc = `[00:00.00] 作词 : 周杰伦
[00:01.00] 作曲 : 周杰伦
[00:12.50]故事的小黄花
[00:15.80]从出生那年就飘着`;

    const lines = parseLyrics(lrc);
    expect(lines).toHaveLength(2); // skip metadata lines
    expect(lines[0].time).toBeCloseTo(12.5, 1);
    expect(lines[0].text).toBe('故事的小黄花');
    expect(lines[1].time).toBeCloseTo(15.8, 1);
    expect(lines[1].text).toBe('从出生那年就飘着');
  });

  it('handles empty lyrics', () => {
    const lines = parseLyrics('');
    expect(lines).toHaveLength(0);
  });

  it('merges translation lyrics', () => {
    const lrc = '[00:12.50]Hello world';
    const tlyric = '[00:12.50]你好世界';
    const lines = parseLyrics(lrc, tlyric);
    expect(lines[0].text).toBe('Hello world');
    expect(lines[0].translation).toBe('你好世界');
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/music/netease.test.ts Expected: FAIL

  • Step 3: Write implementation

Create src/music/netease.ts:

import axios, { type AxiosInstance } from 'axios';
import type {
  MusicProvider, Song, SongWithUrl, Playlist, Album,
  LyricLine, SearchResult, QrCodeResult, AuthStatus,
} from './provider.js';

export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
  if (!lrc) return [];

  const parseLine = (line: string): { time: number; text: string } | null => {
    const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)$/);
    if (!match) return null;
    const minutes = parseInt(match[1], 10);
    const seconds = parseInt(match[2], 10);
    const ms = parseInt(match[3].padEnd(3, '0'), 10);
    const text = match[4].trim();

    // Skip metadata lines (作词, 作曲, 编曲, etc.)
    if (/^(作词|作曲|编曲|制作|混音|母带)\s*[::]/.test(text)) return null;

    return { time: minutes * 60 + seconds + ms / 1000, text };
  };

  const lines: LyricLine[] = [];
  const translationMap = new Map<number, string>();

  // Parse translation lyrics first
  if (tlyric) {
    for (const line of tlyric.split('\n')) {
      const parsed = parseLine(line);
      if (parsed) {
        translationMap.set(Math.round(parsed.time * 100), parsed.text);
      }
    }
  }

  for (const line of lrc.split('\n')) {
    const parsed = parseLine(line);
    if (parsed) {
      const timeKey = Math.round(parsed.time * 100);
      lines.push({
        time: parsed.time,
        text: parsed.text,
        translation: translationMap.get(timeKey),
      });
    }
  }

  return lines.sort((a, b) => a.time - b.time);
}

export class NeteaseProvider implements MusicProvider {
  readonly platform = 'netease' as const;
  private api: AxiosInstance;
  private cookie = '';

  constructor(baseUrl: string) {
    this.api = axios.create({
      baseURL: baseUrl,
      timeout: 10000,
    });
  }

  private get cookieParams(): Record<string, string> {
    return this.cookie ? { cookie: this.cookie } : {};
  }

  async search(query: string, limit = 20): Promise<SearchResult> {
    const [songRes, playlistRes] = await Promise.all([
      this.api.get('/cloudsearch', {
        params: { keywords: query, type: 1, limit, ...this.cookieParams },
      }),
      this.api.get('/cloudsearch', {
        params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams },
      }),
    ]);

    const songs: Song[] = (songRes.data?.result?.songs ?? []).map((s: any) => ({
      id: String(s.id),
      name: s.name,
      artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
      album: s.al?.name ?? '',
      duration: Math.round((s.dt ?? 0) / 1000),
      coverUrl: s.al?.picUrl ?? '',
      platform: 'netease',
    }));

    const playlists: Playlist[] = (playlistRes.data?.result?.playlists ?? []).map((p: any) => ({
      id: String(p.id),
      name: p.name,
      coverUrl: p.coverImgUrl ?? '',
      songCount: p.trackCount ?? 0,
      platform: 'netease',
    }));

    return { songs, playlists, albums: [] };
  }

  async getSongUrl(songId: string): Promise<string | null> {
    const res = await this.api.get('/song/url/v1', {
      params: { id: songId, level: 'exhigh', ...this.cookieParams },
    });
    const url = res.data?.data?.[0]?.url;
    return url ?? null;
  }

  async getSongDetail(songId: string): Promise<Song | null> {
    const res = await this.api.get('/song/detail', {
      params: { ids: songId, ...this.cookieParams },
    });
    const s = res.data?.songs?.[0];
    if (!s) return null;
    return {
      id: String(s.id),
      name: s.name,
      artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
      album: s.al?.name ?? '',
      duration: Math.round((s.dt ?? 0) / 1000),
      coverUrl: s.al?.picUrl ?? '',
      platform: 'netease',
    };
  }

  async getPlaylistSongs(playlistId: string): Promise<Song[]> {
    const res = await this.api.get('/playlist/track/all', {
      params: { id: playlistId, ...this.cookieParams },
    });
    return (res.data?.songs ?? []).map((s: any) => ({
      id: String(s.id),
      name: s.name,
      artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
      album: s.al?.name ?? '',
      duration: Math.round((s.dt ?? 0) / 1000),
      coverUrl: s.al?.picUrl ?? '',
      platform: 'netease',
    }));
  }

  async getRecommendPlaylists(): Promise<Playlist[]> {
    const res = await this.api.get('/personalized', {
      params: { limit: 10, ...this.cookieParams },
    });
    return (res.data?.result ?? []).map((p: any) => ({
      id: String(p.id),
      name: p.name,
      coverUrl: p.picUrl ?? '',
      songCount: p.trackCount ?? 0,
      platform: 'netease',
    }));
  }

  async getAlbumSongs(albumId: string): Promise<Song[]> {
    const res = await this.api.get('/album', {
      params: { id: albumId, ...this.cookieParams },
    });
    return (res.data?.songs ?? []).map((s: any) => ({
      id: String(s.id),
      name: s.name,
      artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
      album: s.al?.name ?? '',
      duration: Math.round((s.dt ?? 0) / 1000),
      coverUrl: s.al?.picUrl ?? '',
      platform: 'netease',
    }));
  }

  async getLyrics(songId: string): Promise<LyricLine[]> {
    const res = await this.api.get('/lyric', {
      params: { id: songId, ...this.cookieParams },
    });
    return parseLyrics(res.data?.lrc?.lyric ?? '', res.data?.tlyric?.lyric);
  }

  async getQrCode(): Promise<QrCodeResult> {
    const keyRes = await this.api.get('/login/qr/key');
    const key = keyRes.data?.data?.unikey ?? '';
    const createRes = await this.api.get('/login/qr/create', {
      params: { key, qrimg: true },
    });
    return {
      qrUrl: createRes.data?.data?.qrurl ?? '',
      key,
    };
  }

  async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
    const res = await this.api.get('/login/qr/check', {
      params: { key },
    });
    const code = res.data?.code;
    switch (code) {
      case 801: return 'waiting';
      case 802: return 'scanned';
      case 803:
        // Save cookie from response
        if (res.data?.cookie) {
          this.cookie = res.data.cookie;
        }
        return 'confirmed';
      default: return 'expired';
    }
  }

  async sendSmsCode(phone: string): Promise<boolean> {
    const res = await this.api.get('/captcha/sent', {
      params: { phone },
    });
    return res.data?.code === 200;
  }

  async loginWithSms(phone: string, code: string): Promise<boolean> {
    const res = await this.api.get('/captcha/verify', {
      params: { phone, captcha: code },
    });
    if (res.data?.cookie) {
      this.cookie = res.data.cookie;
    }
    return res.data?.code === 200;
  }

  setCookie(cookie: string): void {
    this.cookie = cookie;
  }

  getCookie(): string {
    return this.cookie;
  }

  async getAuthStatus(): Promise<AuthStatus> {
    if (!this.cookie) return { loggedIn: false };
    try {
      const res = await this.api.get('/login/status', {
        params: { ...this.cookieParams },
      });
      const profile = res.data?.data?.profile;
      if (profile) {
        return {
          loggedIn: true,
          nickname: profile.nickname,
          avatarUrl: profile.avatarUrl,
        };
      }
    } catch {
      // ignore
    }
    return { loggedIn: false };
  }

  async getPersonalFm(): Promise<Song[]> {
    const res = await this.api.get('/personal_fm', {
      params: { ...this.cookieParams },
    });
    return (res.data?.data ?? []).map((s: any) => ({
      id: String(s.id),
      name: s.name,
      artist: (s.artists ?? []).map((a: any) => a.name).join(' / '),
      album: s.album?.name ?? '',
      duration: Math.round((s.duration ?? 0) / 1000),
      coverUrl: s.album?.picUrl ?? '',
      platform: 'netease',
    }));
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/music/netease.test.ts Expected: 3 tests PASS

  • Step 5: Commit
git add src/music/netease.ts src/music/netease.test.ts
git commit -m "feat: add NetEase Cloud Music provider with search, playlist, lyrics, and auth"

Task 4: QQ Music adapter

Files:

  • Create: src/music/qq.ts

  • Step 1: Write implementation

Create src/music/qq.ts:

import axios, { type AxiosInstance } from 'axios';
import type {
  MusicProvider, Song, Playlist, Album,
  LyricLine, SearchResult, QrCodeResult, AuthStatus,
} from './provider.js';
import { parseLyrics } from './netease.js';

export class QQMusicProvider implements MusicProvider {
  readonly platform = 'qq' as const;
  private api: AxiosInstance;
  private cookie = '';

  constructor(baseUrl: string) {
    this.api = axios.create({
      baseURL: baseUrl,
      timeout: 10000,
    });
  }

  private get cookieParams(): Record<string, string> {
    return this.cookie ? { cookie: this.cookie } : {};
  }

  async search(query: string, limit = 20): Promise<SearchResult> {
    const res = await this.api.get('/search', {
      params: { key: query, pageSize: limit, ...this.cookieParams },
    });

    const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({
      id: String(s.songmid ?? s.id),
      name: s.songname ?? s.name ?? '',
      artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
      album: s.albumname ?? '',
      duration: s.interval ?? 0,
      coverUrl: s.albummid
        ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
        : '',
      platform: 'qq',
    }));

    return { songs, playlists: [], albums: [] };
  }

  async getSongUrl(songId: string): Promise<string | null> {
    const res = await this.api.get('/song/url', {
      params: { id: songId, ...this.cookieParams },
    });
    return res.data?.data ?? null;
  }

  async getSongDetail(songId: string): Promise<Song | null> {
    const res = await this.api.get('/song', {
      params: { songmid: songId, ...this.cookieParams },
    });
    const s = res.data?.data;
    if (!s) return null;
    return {
      id: String(s.mid ?? s.id),
      name: s.name ?? '',
      artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
      album: s.album?.name ?? '',
      duration: s.interval ?? 0,
      coverUrl: s.album?.mid
        ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
        : '',
      platform: 'qq',
    };
  }

  async getPlaylistSongs(playlistId: string): Promise<Song[]> {
    const res = await this.api.get('/songlist', {
      params: { id: playlistId, ...this.cookieParams },
    });
    return (res.data?.data?.songlist ?? []).map((s: any) => ({
      id: String(s.songmid ?? s.id),
      name: s.songname ?? s.name ?? '',
      artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
      album: s.albumname ?? '',
      duration: s.interval ?? 0,
      coverUrl: s.albummid
        ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
        : '',
      platform: 'qq',
    }));
  }

  async getRecommendPlaylists(): Promise<Playlist[]> {
    const res = await this.api.get('/recommend/playlist', {
      params: { ...this.cookieParams },
    });
    return (res.data?.data?.list ?? []).map((p: any) => ({
      id: String(p.content_id ?? p.id),
      name: p.title ?? p.name ?? '',
      coverUrl: p.cover ?? '',
      songCount: p.cnt ?? 0,
      platform: 'qq',
    }));
  }

  async getAlbumSongs(albumId: string): Promise<Song[]> {
    const res = await this.api.get('/album/songs', {
      params: { albummid: albumId, ...this.cookieParams },
    });
    return (res.data?.data?.list ?? []).map((s: any) => ({
      id: String(s.songmid ?? s.id),
      name: s.songname ?? s.name ?? '',
      artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
      album: s.albumname ?? '',
      duration: s.interval ?? 0,
      coverUrl: s.albummid
        ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
        : '',
      platform: 'qq',
    }));
  }

  async getLyrics(songId: string): Promise<LyricLine[]> {
    const res = await this.api.get('/lyric', {
      params: { songmid: songId, ...this.cookieParams },
    });
    return parseLyrics(res.data?.data?.lyric ?? '', res.data?.data?.trans ?? '');
  }

  async getQrCode(): Promise<QrCodeResult> {
    const res = await this.api.get('/login/qr/create');
    return {
      qrUrl: res.data?.data?.qrurl ?? '',
      key: res.data?.data?.key ?? '',
    };
  }

  async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
    const res = await this.api.get('/login/qr/check', {
      params: { key },
    });
    const code = res.data?.data?.code;
    if (code === 0) {
      if (res.data?.data?.cookie) {
        this.cookie = res.data.data.cookie;
      }
      return 'confirmed';
    }
    if (code === 1) return 'scanned';
    if (code === 2) return 'waiting';
    return 'expired';
  }

  setCookie(cookie: string): void {
    this.cookie = cookie;
  }

  getCookie(): string {
    return this.cookie;
  }

  async getAuthStatus(): Promise<AuthStatus> {
    if (!this.cookie) return { loggedIn: false };
    try {
      const res = await this.api.get('/user/detail', {
        params: { ...this.cookieParams },
      });
      if (res.data?.data) {
        return {
          loggedIn: true,
          nickname: res.data.data.nickname,
          avatarUrl: res.data.data.headpic,
        };
      }
    } catch {
      // ignore
    }
    return { loggedIn: false };
  }
}
  • Step 2: Commit
git add src/music/qq.ts
git commit -m "feat: add QQ Music provider with search, playlist, lyrics, and auth"

Files:

  • Create: src/music/auth.ts

  • Step 1: Write implementation

Create src/music/auth.ts:

import fs from 'node:fs';
import path from 'node:path';

export interface CookieStore {
  save(platform: 'netease' | 'qq', cookie: string): void;
  load(platform: 'netease' | 'qq'): string;
}

export function createCookieStore(cookieDir: string): CookieStore {
  if (!fs.existsSync(cookieDir)) {
    fs.mkdirSync(cookieDir, { recursive: true });
  }

  return {
    save(platform: 'netease' | 'qq', cookie: string): void {
      const filePath = path.join(cookieDir, `${platform}.json`);
      fs.writeFileSync(filePath, JSON.stringify({ cookie, updatedAt: new Date().toISOString() }), 'utf-8');
    },

    load(platform: 'netease' | 'qq'): string {
      const filePath = path.join(cookieDir, `${platform}.json`);
      if (!fs.existsSync(filePath)) return '';
      try {
        const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
        return data.cookie ?? '';
      } catch {
        return '';
      }
    },
  };
}
  • Step 2: Commit
git add src/music/auth.ts
git commit -m "feat: add cookie persistence store for music platform authentication"

Task 6: Verify and commit Phase 4

  • Step 1: Run all tests

Run: npx vitest run Expected: All tests pass

  • Step 2: Verify TypeScript compilation

Run: npx tsc --noEmit Expected: No errors

  • Step 3: Commit
git add -A
git commit -m "chore: Phase 4 complete — music source service (NetEase, QQ Music, auth)"