
作为一个经常折腾各种开源音乐播放器的老用户我对Listen 1这个项目一直比较关注。它的定位很清晰聚合多家平台曲库一套界面完成搜索和播放省掉来回切换的麻烦。不过长期以来下载功能始终是它的一个短板在线播放倒也够用但一遇到网络不好或想离线收藏的时候就会觉得差点意思。这个版本列表下载的思路其实是对Listen 1的渲染进程做定向增强。我们知道Listen 1本质上是Electron应用界面和逻辑都跑在Chromium内核里。既然它自己不想做下载那就绕过它的UI直接往它的运行环境里注入一段逻辑让播放列表里的每一首歌都具备触发下载的能力。实际踩下来这条路是能走通的而且不影响原有播放功能。1. 为什么列表下载是刚需看到这里你可能会问在线播放器一大堆我直接网页上听不就行了为什么要费劲去改源码加下载这里有几个很现实的原因。1.1 我听音乐的场景地铁、高铁、地下车库这些场景的信号有多差我相信大家都体会过。在线播放的体验在这种环境下基本是灾难级别的。与其依赖平台自带的缓存很多还加密换设备就失效不如直接把音源文件下载到本地想什么时候听就什么时候听。尤其是对音质有要求的朋友列表下载意味着可以把flac或者高码率mp3囤到手机和电脑里插上播放器或者直接文件管理调用完全不受网络和平台会员的限制。还有一个点就是曲库的稳定性。我用Listen 1最大的痛点之一是某个平台的歌手主页、歌单页面今天能打开明天可能就接口报错歌曲列表直接空白一片。但如果你提前把列表下载到了本地这些问题就跟你就没什么关系了。文件在你自己手里才是真拥有。1.2 从播放列表到本地文件刚才说了现状和痛点。那么列表下载具体能干什么说得直白一点它可以把当前播放队列里所有歌曲的音频数据通过浏览器内置的fetch能力抓回来再在本地拼成一个可播放的音乐文件。这跟“录音内录”那种旁门左道不同列表下载抓取的是源站直接返回的音频流质量是有保证的。也不像某些浏览器插件那样需要依赖站方的公开接口——万一接口变了插件往往直接失效但列表下载策略每次都是从实际网络请求里提取真实地址哪怕接口变了只要页面能放出声音来下载逻辑就能继续用。1.3 适合谁去参考写这篇东西的目的并不是鼓励大家都去扒别人平台的资源。真正适合的读者应该是对前端技术有一点点基础想了解Electron应用逆向增强思路的人。另外就是像“Listen 1列表v2.6”这类版本方案的实践者他们可能已经研究过源码但卡在了下载效率低、一直失败的问题上希望找到一套稳定的列表遍历下载队列方案。2. 动手前的准备环境与代码结构中途我换过一次机器发现很多人会卡在这一步所以专门讲清楚。Listen 1的安装包有安装版和便携版我们加下载功能推荐直接拿便携版或者源码包来操作。个人更倾向于从GitHub拉一份源码来进行构建因为改动起来最自由也方便观察完整逻辑。2.1 版本选择与代码获取我们需要的是v2.6.0及以后的版本。为什么强调这个版本因为在2.6.x这一代音乐播放核心的抽象层已经足够稳定又不至于太新太新的版本可能会因为平台接口的调整增加额外限制。我实际操作的是v2.6.0版本的源码。获取方式很简单在项目仓库的Release页面下载Source codezip或tar.gz均可。如果你本地装了Git直接git clone -b v2.6.0也是可以的但注意要把子模块也拉下来否则部分代码会缺失构建时必然报错。git clone --depth 1 -b v2.6.0 https://github.com/listen1/listen1_chrome_extension.git注意这个仓库不是Electron壳本身而是Chrome扩展版本的实现Listen 1的桌面版核心逻辑仍然沿用扩展的源码只是外层套了不同的运行时。我们的大部分修改都集中在扩展版本的渲染脚本中。2.2 代码结构速览下载完后打开文件夹你会看到类似这样的结构chrome/extension/扩展核心代码js/所有前端逻辑包括播放器逻辑、列表逻辑、页面交互css/样式html/各个面板页面source/或src/部分版本中的ES6源码最终会通过打包工具压缩到js中如果直接在扩展模式下手动加载这个目录修改js/里的代码是即时生效的但如果要重新构建Electron安装包就需要改source里的源文件再执行构建流程。2.3 我的运行环境参考我自己的调试环境是Windows 10 22H2Node.js 14.21.3npm 6.14.18。Electron版本是Listen 1项目锁定的。如果是较新的代码仓库建议按README中的要求安装依赖不要盲目用最新版Node避免遇到版本兼容问题。3. 列表下载的核心原理拆解虽然“加下载功能”听起来很直接但实际操作时你会发现有几个分水岭式的技术点从列表到下载地址从下载地址到保存文件以及最麻烦的跨域限制。3.1 从列表到下载地址Listen 1的播放列表本质上是一个JavaScript数组数组内的每个元素对应一首歌包含标题、歌手、专辑、平台标识、歌曲ID等基础信息。而真正的音频流地址是播放时通过各平台接口动态解析出来的不提前存在列表里。所以下载的第一步是拿到列表后对这些歌曲逐首进行播放地址的解析。我们可以调用Listen 1内部已经写好的分析方法比如player.playByID(platform, id)。这个方法不仅会把歌加入播放队列还会触发异步请求去换取真实的音频文件地址。我们可以借此拿到待下载的URL列表async function fetchDownloadUrl(platform, songId) { const url await generateMusicUrl(platform, songId); return url; }这里generateMusicUrl是内部封装的用于从平台方获取可播放直链。如果直接调用它会返回一个类似https://xxx/music/123.mp3的字符串这就是我们要下载的目标。3.2 从下载地址到保存文件拿到直链之后就可以用fetch来抓取音频数据的二进制内容然后借助浏览器环境的URL.createObjectURL生成一个临时对象地址再通过动态创建a标签模拟点击触发浏览器下载。核心代码如下async function downloadSong(songUrl, filename) { const response await fetch(songUrl, { method: GET, mode: cors, credentials: include }); if (!response.ok) { throw new Error(下载失败: HTTP ${response.status}); } const blob await response.blob(); const objectURL window.URL.createObjectURL(blob); const anchor document.createElement(a); anchor.href objectURL; anchor.download filename; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); window.URL.revokeObjectURL(objectURL); }这么做的好处是不用经过Node.js侧写文件系统完全利用Chromium的下载机制兼容性极好不需要修改Electron主进程。3.3 跨域问题是关键不过这里会有个大坑fetch直接访问第三方音乐平台的音频地址通常会遇到CORS跨源资源共享限制说人话就是这些平台的服务器没有允许来自Listen 1页面源的文件读取浏览器会拦截响应导致fetch拿不到数据。网上很多方案是建议修改manifest.json添加对应的host_permissions。这在扩展模式下确实可以解决一部分问题因为扩展的跨域权限比普通网页大。但某些音频服务器仍然不会返回CORS头导致转存到Blob这步继续失败。实操提示我最常用的做法是解析到直链后先判断该域名是否在CORS白名单内。如果不在就改用扩展模式下的后台代理请求或者通过Electron主进程的net模块请求。在v2.6版本中直接主进程代理是改动量最小的路径。4. 实操一步步给Listen 1 v2.6注入下载能力下面进入正题。整个操作不复杂但涉及文件的准确修改。我以源码包方式为例一步步带你走完。4.1 第一步定位核心播放器代码打开chrome/extension/js/目录找到player.js或player-engine.js这取决于你拉到的源码版本。v2.6.0中核心播放器文件是player.js。打开后你会看到一个大对象player里面有playByID、play、switchSong这些核心方法。先不急着改往下翻找到歌词、收藏等辅助逻辑我们会在文件末尾追加一组与下载相关的新方法避免影响原生逻辑。4.2 第二步写入下载队列管理逻辑我们不想一次性把列表里所有歌同时拉下来那样网络连接会爆炸也容易被平台限流。所以需要一个简单的队列管理逐首下载每首下载完再自动开始下一首。在player.js末尾追加player.downloadQueue []; player.isDownloading false; player.addDownloadList function(songList) { for (let i 0; i songList.length; i) { this.downloadQueue.push(songList[i]); } this.nextDownload(); }; player.nextDownload function() { if (this.isDownloading || this.downloadQueue.length 0) { return; } const song this.downloadQueue.shift(); this.startDownloadSong(song); }; player.startDownloadSong function(song) { this.isDownloading true; // 首先解析真实下载地址 this.getRealAudioUrl(song).then(url { return this.downloadAudioFile(url, song); }).then(() { this.isDownloading false; console.log([Download] 完成: song.title); this.nextDownload(); }).catch(err { this.isDownloading false; console.error([Download] 失败: song.title, err); this.nextDownload(); }); };之所以写成队列而不是循环并发是因为音乐平台往往对短时间内的高频请求有限流容易触发验证码或者临时封禁。逐首串行更稳虽然慢点但成功率最高。4.3 第三步实现音频文件抓取与落盘接下来实现getRealAudioUrl和downloadAudioFile。前者解析直链后者负责二进制抓取。Listen 1中不同平台解析歌曲播放地址的方法不统一但基本上都会有一个getRealUrl之类的内部方法。你可以顺着现有播放调用链找。比如原代码中播放一首歌大概会走这样的调用player.play function(song) { // 内部按song.platform分发到不同的解析模块 // 最终拿到 song.url 后交给 audio 元素播放 };所以我们可以利用这个现成逻辑player.getRealAudioUrl async function(song) { const platform song.platform; const resolver window.platformResolvers[platform]; if (resolver resolver.getRealAudioUrl) { const result await resolver.getRealAudioUrl(song); return result.url; } throw new Error(未知平台: platform); };注意platformResolvers这个全局对象在v2.6中是存在的如果没有你就需要自己根据平台标识写一段形如Promise.resolve(https://...)的模拟逻辑。下载音频文件的部分就是前面提到的fetch Blob 触发下载player.downloadAudioFile async function(url, song) { let response; try { response await fetch(url, { credentials: include, headers: { Referer: https://static.listen1.me/, User-Agent: navigator.userAgent } }); } catch (e) { throw new Error(网络请求失败); } if (!response.ok) { throw new Error(HTTP response.status); } const blob await response.blob(); const filename song.title - song.artist . this.guessFileExt(url, blob.type); const objectURL window.URL.createObjectURL(blob); const anchor document.createElement(a); anchor.href objectURL; anchor.download filename; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); window.setTimeout(() { window.URL.revokeObjectURL(objectURL); }, 1000); };这里有两个细节设置了Referer和User-Agent。因为部分站点会检查来源和UA如果发现是从扩展页发起的请求会拒绝返回数据。伪装成从正常页面访问下载成功率会高很多。用guessFileExt推测扩展名。虽然可以写死.mp3但有些音源可能是.flac或.m4a写死会导致文件实际格式与后缀不符播放器可能无法识别。guessFileExt的简单实现player.guessFileExt function(url, mime) { const cleanUrl url.split(?)[0].toLowerCase(); if (cleanUrl.endsWith(.mp3)) return mp3; if (cleanUrl.endsWith(.flac)) return flac; if (cleanUrl.endsWith(.m4a) || cleanUrl.endsWith(.aac)) return m4a; if (mime.indexOf(flac) 0) return flac; if (mime.indexOf(mp3) 0) return mp3; return bin; };4.4 第四步给列表页加个下载按钮只有代码逻辑还不够UI上总得有个入口。我给播放列表区域加了一个显而易见的“下载全部”这也是实际操作时最直接的入口省得我去找控制台手动输入命令。在播放列表的toolbar区域追加一个按钮。在html中找到对应列表工具条的模板比如chrome/extension/html/popup.html或player.html加入button iddownload-all-from-list下载全部/button然后在JavaScript中绑定点击事件document.getElementById(download-all-from-list).addEventListener(click, function() { const songList player.playList; if (!songList || songList.length 0) { alert(当前列表为空); return; } player.addDownloadList(songList); });这里用到了player.playList它在Listen 1中是当前播放列表的全局引用拿它做数据源最准确不用自己再维护一个列表副本。注意并非所有版本的toolbar都有这个id需要你打开开发者工具确认播放列表容器内的DOM结构然后把按钮放到合适位置避免样式错乱。4.5 第五步加载到Chrome扩展或Electron调试如果你打算以Chrome扩展方式调试打开chrome://extensions开启“开发者模式”点击“加载已解压的扩展程序”选择chrome/extension目录。修改代码后点击扩展卡片上的刷新按钮重新加载即可。如果是要构建Electron版本更多是在源码根目录执行npm install和npm run build改的就是source/里的源文件。过程中如果遇到编译错误多半是依赖版本不一致先不要动业务代码优先处理依赖锁定问题。5. 遇到的问题与排查技巧这部分大概是整个过程中最有含金量的部分。毕竟代码本身不难难的是如何在不同网络环境、不同平台规则之下稳定跑通。5.1 跨域失败的排查方法最典型的错误是Failed to fetch和No Access-Control-Allow-Origin header is present。遇到这种问题我的排查顺序是先确认是不是真的走了fetch。如果Listen 1内部解析的URL本身就是通过后台代理拿到的那扩展或主进程已经替你解决了跨域不需要额外处理。检查目标域名是否返回了CORS头。打开开发者工具Network面板查看音频请求的Response Headers里有没有Access-Control-Allow-Origin。如果没有在高版本Chrome里没法绕过必须改用后台代理请求。当时我在给某个不太常见的平台加下载时就遇到了CORS头缺失。最终选择在后台脚本里加了一个代理接口让renderer进程把音频URL传给后台由后台以Node.js/Electron的方式请求拿到数据后再传回渲染进程问题迎刃而解。不过需要说明的是基于Electron主进程的代理会稍微复杂要新建一个ipcRenderer/ipcMain通信逻辑。考虑到篇幅有限这里只记录思路具体代码可在项目内参考Electron官方的protocol.handle文档。5.2 下载到一半卡住现象是点击“下载全部”后第一首下完第二首永远不开始也没有报错。这个问题大概率出在Promise链的某个分支丢失了。仔细检查startDownloadSong是否在getRealAudioUrl的then里忘记return。如果没有return后面的downloadAudioFile永远不会被调用Promise却已经resolve状态标记被重置为未下载中队列当然就卡了。我在初版代码里就踩过这个坑player.getRealAudioUrl(song).then(url { this.downloadAudioFile(url, song); // 少了 return }).then(() { // 永远不会执行到这里 });加上return后队列才跑得顺否则总是第一首卡死白折腾。5.3 文件名乱码或后缀不对因为播放列表里的歌曲标题一般都包含中文如果直接拼接进anchor.download部分Windows环境可能显示成乱码。这时候需要在文件名生成时做一次URL编码或使用浏览器默认的下载管理策略。最简单的方式是保留原始字符串现代浏览器会自动做UTF-8到本地文件名的转换。但如果你的Electron或Chromium版本过旧可能就会乱码。我建议直接把文件名中的非法字符替换掉比如把/和\替换成-把?和*去掉。5.4 与原生播放逻辑冲突有时候注入之后会发现下载过程中播放器自动暂停或者切歌了。原因是getRealAudioUrl解析时可能会改变当前音源或触发播放状态切换。为了解决这个副作用我在startDownloadSong开头加了一个标志位人为阻断播放器的自动切歌事件。这个做法不能一概而论你需要观察你当前版本的播放器事件触发机制。如果发现下载一首歌切一次歌直接检查是否在解析过程中误调用了player.play。5.5 常见错误速查表错误现象可能原因解决办法Fetch失败跨域CORS使用代理请求文件后缀.binMIME和URL无扩展名改进guessFileExt队列卡住Promise未return检查then返回值下载按钮无反应DOM选择器定位错误重新审查元素id下载速度很慢平台限流增加重试延时文件名乱码编码问题替换非法字符6. 更实用的扩展列表并发与重试策略前面讲的串行下载稳定是稳定但效率太低了。假如一个歌单有100首歌每首5MB即使每首3秒下完也得5分钟。后来我优化了一下策略加入了并发控制机制每次最多同时下载3首并为失败项增加自动重试。6.1 实现简单的并发限制核心思路是维护一个计数器和待下载数组一旦某个下载任务完成就从队列里取出下一个任务补充进去。这也是列表下载方法里最简单的一种可控并发。player.downloadConcurrency 3; player.activeDownloadCount 0; player.addDownloadList function(songList) { this.downloadQueue songList.slice(); this.activeDownloadCount 0; this.startQueue(); }; player.startQueue function() { while (this.activeDownloadCount this.downloadConcurrency this.downloadQueue.length 0) { const song this.downloadQueue.shift(); this.activeDownloadCount; this.startDownloadSong(song).finally(() { this.activeDownloadCount--; this.startQueue(); }); } };并发数设成3是权衡了平台风控和下载速度的结果。2可能稍慢5又容易被限制实测3最稳妥。6.2 失败自动重试引入重试机制就是在downloadAudioFile外层包一层retry函数player.retryDownload async function(fn, retries 3, delay 2000) { for (let attempt 1; attempt retries; attempt) { try { return await fn(); } catch (err) { console.warn(第 ${attempt} 次下载失败: ${err.message}); if (attempt retries) { await new Promise(resolve setTimeout(resolve, delay * attempt)); } else { throw err; } } } };注意重试间隔要递增否则短时间密集重试会被平台封IP。实际用下来重试间隔2秒起步每次翻倍三次后基本能解决临时性网络波动。7. 日志与导航到本地文件夹虽然功能已经实现了但用起来还是不够方便。最后我加了两段辅助逻辑大大提升了日常使用体验。7.1 下载完成后通知下载完成时如果能用系统通知提醒一下就不用在电脑前盯着进度条了。用Electron的Notification接口或者Web Notifications API都行。if (window.Notification Notification.permission granted) { new Notification(下载完成, { body: song.title }); }7.2 打开下载目录如果你从Electron的主进程中调用还可以做到下载后自动打开文件夹。但扩展模式下做不到只能定位到浏览器下载目录。这对我个人来说最大的好处是下载完不用手动去文件管理器里翻来找去。如果你对效率有追求这是很推荐的增量更新。8. 给普通用户的最终建议经过这几轮折腾我的感受是给Listen 1加列表下载功能本质上并非依赖什么黑科技而是利用它已有播放能力把网络请求的结果保存下来。真正的工作量在于适配不同平台的解析策略以及处理跨域和限流。如果你只是普通用户并不想折腾源码建议直接用现成的下载方案比如某些二开版本已经内置了下载按钮。但如果是自己做二次开发或者想学习Electron扩展的跨界通信、队列下载这套设计那自己动手走一遍收获会大得多。最后罗列几条个人经验动手前务必备份原代码尤其是player.js改坏了瞬间恢复。下载逻辑尽量独立成模块不要在原播放方法上直接加料避免破坏原有事件链。调试时先开开发者工具看Console日志错误信息里90%的问题都能定位。遇到平台限流不要慌把并发调低把重试间隔拉长。这个列表下载方法的实现是一个标准的前端工程问题跟具体某个工具版本关系不大。核心的思路——队列、并发、重试、跨域规避——在任何需要批量抓取的场景里都通用。希望这篇整理能帮你少走点弯路一次就把下载功能跑起来。