☰
Listen 1列表下载实战:Electron渲染进程注入与CORS跨域处理
2026/9/30 5:23:41 网站建设 项目流程

作为一个经常折腾各种开源音乐播放器的老用户,我对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 code(zip或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 22H2,Node.js 14.21.3,npm 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 id="download-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。

遇到这种问题,我的排查顺序是:

  1. 先确认是不是真的走了fetch。如果Listen 1内部解析的URL本身就是通过后台代理拿到的,那扩展或主进程已经替你解决了跨域,不需要额外处理。
  2. 检查目标域名是否返回了CORS头。打开开发者工具Network面板,查看音频请求的Response Headers里有没有Access-Control-Allow-Origin。
  3. 如果没有,在高版本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扩展的跨界通信、队列下载这套设计,那自己动手走一遍,收获会大得多。

最后罗列几条个人经验:

  1. 动手前务必备份原代码,尤其是player.js,改坏了瞬间恢复。
  2. 下载逻辑尽量独立成模块,不要在原播放方法上直接加料,避免破坏原有事件链。
  3. 调试时先开开发者工具看Console日志,错误信息里90%的问题都能定位。
  4. 遇到平台限流不要慌,把并发调低,把重试间隔拉长。

这个列表下载方法的实现,是一个标准的前端工程问题,跟具体某个工具版本关系不大。核心的思路——队列、并发、重试、跨域规避——在任何需要批量抓取的场景里都通用。希望这篇整理能帮你少走点弯路,一次就把下载功能跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询