From 0374b77d160d85667a1b7d82297e97504b8f548a Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 19 Mar 2026 05:06:48 +0800 Subject: [PATCH] feat: Introduce Netease Music CLI with CDP enabler --- src/clis/neteasemusic/README.md | 31 ++++++++++++++ src/clis/neteasemusic/README.zh-CN.md | 31 ++++++++++++++ src/clis/neteasemusic/like.ts | 28 ++++++++++++ src/clis/neteasemusic/lyrics.ts | 53 +++++++++++++++++++++++ src/clis/neteasemusic/next.ts | 30 +++++++++++++ src/clis/neteasemusic/play.ts | 30 +++++++++++++ src/clis/neteasemusic/playing.ts | 62 +++++++++++++++++++++++++++ src/clis/neteasemusic/playlist.ts | 51 ++++++++++++++++++++++ src/clis/neteasemusic/prev.ts | 29 +++++++++++++ src/clis/neteasemusic/search.ts | 58 +++++++++++++++++++++++++ src/clis/neteasemusic/status.ts | 18 ++++++++ src/clis/neteasemusic/volume.ts | 61 ++++++++++++++++++++++++++ 12 files changed, 482 insertions(+) create mode 100644 src/clis/neteasemusic/README.md create mode 100644 src/clis/neteasemusic/README.zh-CN.md create mode 100644 src/clis/neteasemusic/like.ts create mode 100644 src/clis/neteasemusic/lyrics.ts create mode 100644 src/clis/neteasemusic/next.ts create mode 100644 src/clis/neteasemusic/play.ts create mode 100644 src/clis/neteasemusic/playing.ts create mode 100644 src/clis/neteasemusic/playlist.ts create mode 100644 src/clis/neteasemusic/prev.ts create mode 100644 src/clis/neteasemusic/search.ts create mode 100644 src/clis/neteasemusic/status.ts create mode 100644 src/clis/neteasemusic/volume.ts diff --git a/src/clis/neteasemusic/README.md b/src/clis/neteasemusic/README.md new file mode 100644 index 0000000..88d027e --- /dev/null +++ b/src/clis/neteasemusic/README.md @@ -0,0 +1,31 @@ +# NeteaseMusic Desktop Adapter (网易云音乐) + +Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF). + +## Prerequisites + +Launch with remote debugging port: +```bash +/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234 +``` + +## Setup + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234" +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `neteasemusic status` | Check CDP connection | +| `neteasemusic playing` | Current song info (title, artist, album) | +| `neteasemusic play` | Play / Pause toggle | +| `neteasemusic next` | Skip to next song | +| `neteasemusic prev` | Go to previous song | +| `neteasemusic search "query"` | Search songs, artists | +| `neteasemusic playlist` | Show current playback queue | +| `neteasemusic like` | Like / unlike current song | +| `neteasemusic lyrics` | Get lyrics of current song | +| `neteasemusic volume [0-100]` | Get or set volume | diff --git a/src/clis/neteasemusic/README.zh-CN.md b/src/clis/neteasemusic/README.zh-CN.md new file mode 100644 index 0000000..9b3e5ae --- /dev/null +++ b/src/clis/neteasemusic/README.zh-CN.md @@ -0,0 +1,31 @@ +# 网易云音乐桌面端适配器 + +通过 Chrome DevTools Protocol (CDP) 在终端中控制 **网易云音乐**。该应用基于 Chromium Embedded Framework (CEF)。 + +## 前置条件 + +通过远程调试端口启动: +```bash +/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234 +``` + +## 配置 + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234" +``` + +## 命令 + +| 命令 | 说明 | +|------|------| +| `neteasemusic status` | 检查 CDP 连接 | +| `neteasemusic playing` | 当前播放歌曲信息 | +| `neteasemusic play` | 播放 / 暂停切换 | +| `neteasemusic next` | 下一首 | +| `neteasemusic prev` | 上一首 | +| `neteasemusic search "关键词"` | 搜索歌曲 | +| `neteasemusic playlist` | 显示当前播放列表 | +| `neteasemusic like` | 喜欢 / 取消喜欢 | +| `neteasemusic lyrics` | 获取当前歌词 | +| `neteasemusic volume [0-100]` | 获取或设置音量 | diff --git a/src/clis/neteasemusic/like.ts b/src/clis/neteasemusic/like.ts new file mode 100644 index 0000000..1d162cc --- /dev/null +++ b/src/clis/neteasemusic/like.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const likeCommand = cli({ + site: 'neteasemusic', + name: 'like', + description: 'Like/unlike the currently playing song', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + const result = await page.evaluate(` + (function() { + // The like/heart button in the player bar + const btn = document.querySelector('.m-playbar .icn-love, .m-playbar [class*="like"], .m-player [class*="love"], [data-action="like"]'); + if (!btn) return 'Like button not found'; + + const wasLiked = btn.classList.contains('loved') || btn.classList.contains('active') || btn.getAttribute('data-liked') === 'true'; + btn.click(); + return wasLiked ? 'Unliked' : 'Liked'; + })() + `); + + return [{ Status: result }]; + }, +}); diff --git a/src/clis/neteasemusic/lyrics.ts b/src/clis/neteasemusic/lyrics.ts new file mode 100644 index 0000000..d7b3017 --- /dev/null +++ b/src/clis/neteasemusic/lyrics.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const lyricsCommand = cli({ + site: 'neteasemusic', + name: 'lyrics', + description: 'Get the lyrics of the currently playing song', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Line'], + func: async (page: IPage) => { + // Try to open lyrics panel if not visible + await page.evaluate(` + (function() { + const btn = document.querySelector('.m-playbar .icn-lyric, [class*="lyric-btn"], [data-action="lyric"]'); + if (btn) btn.click(); + })() + `); + + await page.wait(1); + + const lyrics = await page.evaluate(` + (function() { + // Look for lyrics container + const selectors = [ + '.m-lyric p, .m-lyric [class*="line"]', + '[class*="lyric-content"] p', + '.listlyric li', + '[class*="lyric"] [class*="line"]', + '.j-lyric p', + ]; + + for (const sel of selectors) { + const nodes = document.querySelectorAll(sel); + if (nodes.length > 0) { + return Array.from(nodes).map(n => (n.textContent || '').trim()).filter(l => l.length > 0); + } + } + + // Fallback: try the body text for any lyrics-like content + return []; + })() + `); + + if (lyrics.length === 0) { + return [{ Line: 'No lyrics found. Try opening the lyrics panel first.' }]; + } + + return lyrics.map((line: string) => ({ Line: line })); + }, +}); diff --git a/src/clis/neteasemusic/next.ts b/src/clis/neteasemusic/next.ts new file mode 100644 index 0000000..73fe244 --- /dev/null +++ b/src/clis/neteasemusic/next.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const nextCommand = cli({ + site: 'neteasemusic', + name: 'next', + description: 'Skip to the next song', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + const clicked = await page.evaluate(` + (function() { + const btn = document.querySelector('.m-playbar .btnfwd, .m-playbar [class*="next"], .m-player .btn-next, [data-action="next"]'); + if (btn) { btn.click(); return true; } + return false; + })() + `); + + if (!clicked) { + // Fallback: Ctrl+Right is common next-track shortcut + await page.pressKey('Control+ArrowRight'); + } + + await page.wait(1); + return [{ Status: 'Skipped to next song' }]; + }, +}); diff --git a/src/clis/neteasemusic/play.ts b/src/clis/neteasemusic/play.ts new file mode 100644 index 0000000..ee98a0b --- /dev/null +++ b/src/clis/neteasemusic/play.ts @@ -0,0 +1,30 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const playCommand = cli({ + site: 'neteasemusic', + name: 'play', + description: 'Toggle play/pause for the current song', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + // Click the play/pause button or use Space key + const clicked = await page.evaluate(` + (function() { + const btn = document.querySelector('.m-playbar .btnp, .m-playbar [class*="play"], .m-player .btn-play, [data-action="play"]'); + if (btn) { btn.click(); return true; } + return false; + })() + `); + + if (!clicked) { + // Fallback: use Space key which is the universal play/pause shortcut + await page.pressKey('Space'); + } + + return [{ Status: 'Play/Pause toggled' }]; + }, +}); diff --git a/src/clis/neteasemusic/playing.ts b/src/clis/neteasemusic/playing.ts new file mode 100644 index 0000000..78144bb --- /dev/null +++ b/src/clis/neteasemusic/playing.ts @@ -0,0 +1,62 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const playingCommand = cli({ + site: 'neteasemusic', + name: 'playing', + description: 'Get the currently playing song info', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Title', 'Artist', 'Album', 'Duration', 'Progress'], + func: async (page: IPage) => { + const info = await page.evaluate(` + (function() { + // NeteaseMusic player bar is at the bottom + const selectors = { + title: '.m-playbar .j-song .name, .m-playbar .song .name, [class*="playing"] .name, .m-player .name', + artist: '.m-playbar .j-song .by, .m-playbar .song .by, [class*="playing"] .artist, .m-player .by', + album: '.m-playbar .j-song .album, [class*="playing"] .album', + time: '.m-playbar .j-dur, .m-playbar .time, .m-player .time', + progress: '.m-playbar .barbg .rng, .m-playbar [role="progressbar"], .m-player [class*="progress"]', + }; + + function getText(sel) { + for (const s of sel.split(',')) { + const el = document.querySelector(s.trim()); + if (el) return (el.textContent || el.innerText || '').trim(); + } + return ''; + } + + const title = getText(selectors.title); + const artist = getText(selectors.artist); + const album = getText(selectors.album); + const time = getText(selectors.time); + + // Try to get playback progress from the progress bar width + let progress = ''; + const bar = document.querySelector('.m-playbar .barbg .rng, [class*="progress"] [class*="played"]'); + if (bar) { + const style = bar.getAttribute('style') || ''; + const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/); + if (match) progress = match[1] + '%'; + } + + if (!title) { + // Fallback: try document title which often contains "songName - NeteaseMusic" + const docTitle = document.title; + if (docTitle && !docTitle.includes('NeteaseMusic')) { + return { Title: docTitle, Artist: '', Album: '', Duration: '', Progress: '' }; + } + return { Title: 'No song playing', Artist: '—', Album: '—', Duration: '—', Progress: '—' }; + } + + return { Title: title, Artist: artist, Album: album, Duration: time, Progress: progress }; + })() + `); + + return [info]; + }, +}); diff --git a/src/clis/neteasemusic/playlist.ts b/src/clis/neteasemusic/playlist.ts new file mode 100644 index 0000000..323948e --- /dev/null +++ b/src/clis/neteasemusic/playlist.ts @@ -0,0 +1,51 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const playlistCommand = cli({ + site: 'neteasemusic', + name: 'playlist', + description: 'Show the current playback queue / playlist', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Title', 'Artist'], + func: async (page: IPage) => { + // Open the playlist panel (usually a button at the bottom bar) + await page.evaluate(` + (function() { + const btn = document.querySelector('.m-playbar .icn-list, .m-playbar [class*="playlist"], [data-action="playlist"], .m-playbar .btnlist'); + if (btn) btn.click(); + })() + `); + + await page.wait(1); + + const items = await page.evaluate(` + (function() { + const results = []; + // Playlist panel items + const rows = document.querySelectorAll('.m-playlist li, [class*="playlist-panel"] li, .listlyric li, .j-playlist li'); + + rows.forEach((row, i) => { + const nameEl = row.querySelector('.name, [class*="name"], a, span:first-child'); + const artistEl = row.querySelector('.by, [class*="artist"], .ar'); + + const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : (row.textContent || '').trim(); + const artist = artistEl ? (artistEl.textContent || '').trim() : ''; + + if (title && title.length > 0) { + results.push({ Index: i + 1, Title: title.substring(0, 80), Artist: artist }); + } + }); + + return results; + })() + `); + + if (items.length === 0) { + return [{ Index: 0, Title: 'Playlist is empty or panel not open', Artist: '—' }]; + } + return items; + }, +}); diff --git a/src/clis/neteasemusic/prev.ts b/src/clis/neteasemusic/prev.ts new file mode 100644 index 0000000..f08aad2 --- /dev/null +++ b/src/clis/neteasemusic/prev.ts @@ -0,0 +1,29 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const prevCommand = cli({ + site: 'neteasemusic', + name: 'prev', + description: 'Go back to the previous song', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status'], + func: async (page: IPage) => { + const clicked = await page.evaluate(` + (function() { + const btn = document.querySelector('.m-playbar .btnbak, .m-playbar [class*="prev"], .m-player .btn-prev, [data-action="prev"]'); + if (btn) { btn.click(); return true; } + return false; + })() + `); + + if (!clicked) { + await page.pressKey('Control+ArrowLeft'); + } + + await page.wait(1); + return [{ Status: 'Went to previous song' }]; + }, +}); diff --git a/src/clis/neteasemusic/search.ts b/src/clis/neteasemusic/search.ts new file mode 100644 index 0000000..ae992b7 --- /dev/null +++ b/src/clis/neteasemusic/search.ts @@ -0,0 +1,58 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const searchCommand = cli({ + site: 'neteasemusic', + name: 'search', + description: 'Search for songs, artists, albums, or playlists', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'query', required: true, positional: true, help: 'Search query' }], + columns: ['Index', 'Title', 'Artist'], + func: async (page: IPage, kwargs: any) => { + const query = kwargs.query as string; + + // Focus and fill the search box + await page.evaluate(` + (function(q) { + const input = document.querySelector('.m-search input, #srch, [class*="search"] input, input[type="search"]'); + if (!input) throw new Error('Search input not found'); + input.focus(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, q); + input.dispatchEvent(new Event('input', { bubbles: true })); + })(${JSON.stringify(query)}) + `); + + await page.pressKey('Enter'); + await page.wait(2); + + // Scrape results + const results = await page.evaluate(` + (function() { + const items = []; + // Song list items in search results + const rows = document.querySelectorAll('.srchsongst li, .m-table tbody tr, [class*="songlist"] [class*="item"], table tbody tr'); + + rows.forEach((row, i) => { + if (i >= 20) return; + const nameEl = row.querySelector('.sn, .name a, [class*="songName"], td:nth-child(2) a, b[title]'); + const artistEl = row.querySelector('.ar, .artist, [class*="artist"], td:nth-child(4) a, td:nth-child(3) a'); + + const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : ''; + const artist = artistEl ? (artistEl.getAttribute('title') || artistEl.textContent || '').trim() : ''; + + if (title) items.push({ Index: i + 1, Title: title, Artist: artist }); + }); + + return items; + })() + `); + + if (results.length === 0) { + return [{ Index: 0, Title: `No results for "${query}"`, Artist: '—' }]; + } + return results; + }, +}); diff --git a/src/clis/neteasemusic/status.ts b/src/clis/neteasemusic/status.ts new file mode 100644 index 0000000..78d8f33 --- /dev/null +++ b/src/clis/neteasemusic/status.ts @@ -0,0 +1,18 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const statusCommand = cli({ + site: 'neteasemusic', + name: 'status', + description: 'Check CDP connection to NeteaseMusic Desktop', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Status', 'Url', 'Title'], + func: async (page: IPage) => { + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + return [{ Status: 'Connected', Url: url, Title: title }]; + }, +}); diff --git a/src/clis/neteasemusic/volume.ts b/src/clis/neteasemusic/volume.ts new file mode 100644 index 0000000..bef900a --- /dev/null +++ b/src/clis/neteasemusic/volume.ts @@ -0,0 +1,61 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const volumeCommand = cli({ + site: 'neteasemusic', + name: 'volume', + description: 'Get or set the volume level (0-100)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'level', required: false, positional: true, help: 'Volume level 0-100 (omit to read current)' }, + ], + columns: ['Status', 'Volume'], + func: async (page: IPage, kwargs: any) => { + const level = kwargs.level as string | undefined; + + if (!level) { + // Read current volume + const vol = await page.evaluate(` + (function() { + const bar = document.querySelector('.m-playbar .vol .barbg .rng, [class*="volume"] [class*="progress"], [class*="volume"] [class*="played"]'); + if (bar) { + const style = bar.getAttribute('style') || ''; + const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/); + if (match) return match[1]; + } + + const vol = document.querySelector('.m-playbar .j-vol, [class*="volume-value"]'); + if (vol) return vol.textContent.trim(); + + return 'Unknown'; + })() + `); + + return [{ Status: 'Current', Volume: vol + '%' }]; + } + + // Set volume by clicking on the volume bar at the right position + const targetVol = Math.max(0, Math.min(100, parseInt(level, 10))); + + await page.evaluate(` + (function(target) { + const bar = document.querySelector('.m-playbar .vol .barbg, [class*="volume-bar"], [class*="volume"] [class*="track"]'); + if (!bar) return; + + const rect = bar.getBoundingClientRect(); + const x = rect.left + (rect.width * target / 100); + const y = rect.top + rect.height / 2; + + bar.dispatchEvent(new MouseEvent('click', { + clientX: x, + clientY: y, + bubbles: true, + })); + })(${targetVol}) + `); + + return [{ Status: 'Set', Volume: targetVol + '%' }]; + }, +});