跳到主要内容

音乐播放器的实现

2026-09-15 阅读 127 约 44 分钟


一、问题与架构

1.1 三条约束

约束表现不处理会怎样
A·直链会过期网易云响应带 expi(通常 1200 秒);酷我直链按会话签发,每次都换曲库存的是「最近一次拿到的地址」,几十分钟后全站音乐集体 404
B·音源各异且随时可变网易云有明文接口、支持批量 id;酷我免登录但返回非标准 JSON;Internet Archive 有正规 API + CC 授权每接一个音源都要改播放器、改曲库接口、改管理页
C·读接口被高频匿名调用/api/music/tracks 在每个页面加载时都被调用一次缓存冷时出网;刷新页面等于替访客向第三方打一次调用,也容易被刷

约束 C 是全站唯一「匿名且会触发服务器出网」的接口——既是性能问题,也是滥用面。

1.2 分层

① 音源抽象  IMusicSourceProvider        「平台上某一首,怎么解析成可播放地址?」
② 续期策略  MusicUrlResolver(单例)     「什么时候重解析?失败怎么办?缓存多久?」
③ 快照      MusicCatalogService(单例)  「这一瞬间的曲库长什么样?」
④ 传输      /api/music/tracks            「怎么用最少字节、最少次数把数据交出去?」
⑤ 存储      MusicTrack + 幂等回填        「来源怎么持久化?旧数据怎么平滑升级?」
⑥ 表现      /Admin/Music · global-music.js

第二章拆 ①–⑤ 与 ⑥ 的管理端,第三章拆 ⑥ 的播放器。



二、后端实现

2.1 音源抽象与续期策略

接口设计——Services/MusicSourceProvider.cs:

string Key { get; }              // 机器标识,写进 MusicTrack.Provider
string DisplayName { get; }
string SearchHint { get; }       // 搜索框下方的说明文字
bool SupportsAutoRenew { get; }  // 直链会不会过期 ← 决定一切
bool IsConfigured { get; }       // 缺 key 的音源不出现在管理端
Task<IReadOnlyList<SourceSearchHit>> SearchAsync(...);
Task<SourceResolveResult> ResolveAsync(string sourceKey, ...);
Task<IReadOnlyList<SourceRenewal>> ResolveBatchAsync(...);  // 有默认实现

SupportsAutoRenew 把音源的物理特性升成系统的一等数据,一个布尔驱动三处:解析器决定给谁续期(archive 的曲目永不出网);管理端决定是否显示「刷新直链」按钮;OnGetResolveUrlAsync 据此拒绝「对长期有效的音源做解析」。这是「新增音源零改动」的前提——系统不需要知道 archive 是什么,只需要知道它不用续期。

另三处:

  • SearchHint 是纯 UI 文案字段,写进接口是有意的。archive 一个条目常是整张专辑、点添加会导入最多 30 首,这个差异无法用字段表达,只能让音源自己用业务语言告诉管理员——换来管理端不必为每个音源写 if 分支;
  • ResolveAsync 返回集合而不是单条。archive 的解析天然可能是 1 首也可能是 30 首,不为「大多数情况是单曲」把接口设计窄,否则只能硬编码特判;
  • ResolveBatchAsync 的默认实现是逐首串行,并约定「不要抛异常」(单曲失败放进 SourceRenewal.Error,整批失败返回空集合)。「上游有没有批量接口」是音源自己的事:网易云支持 ids 数组,酷我没有就自己控并发(SemaphoreSlim(4))。

续期策略——Services/MusicUrlResolver.cs 是全项目的技术密度高点,只回答一个问题:会过期的地址,什么时候该重新解析?

动态 TTL——不用固定值,因为固定值可能长于实际有效期,下发出去就是坏链:

if (expiresInSeconds <= 0)   return DefaultTtl;   // 上游没给 → 保守 10 分钟
if (expiresInSeconds <= 120) return null;         // 太短 → 本次不缓存
return TimeSpan.FromSeconds(Math.Clamp(expiresInSeconds - 60, 30, 900));

围绕上游自报的 expi 留 60 秒安全边界并夹到 30–900 秒。酷我拿不到 expi,退回 10 分钟兜底——同为「会过期」,一个走精确分支、一个走保守兜底,因为上游给的信息量不同。

负缓存分两档——区分确定性失败与暂时性失败,因为两者的正确反应相反:

性质例子处理
确定性不可播放网易云 -110(无版权)/ -447(需会员)抑制重试 10 分钟
暂时性失败风控、网络抖动、该音质恰好没版权40 秒后重试

两个静态工厂把语义固化在类型里:SourceRenewal.Fail(...) 与 SourceRenewal.Unplayable(key, error, suppressRetryFor)。默认一律按「稍后重试」——误判成 10 分钟会让刚上架的曲目迟迟不出现,代价只是多试几次上游。

批量合并——20 首网易云冷缓存最多出网 3 次(对应 3 个音质档),不是 60 次:逐级降音质 × 每级整体批次。解析器再按 Provider 分组,不同音源各发各的(keyToRef 的映射只在单批内有效,因为不同音源的 SourceKey 可能都是数字 id 而撞车)。

闸门 + 双检——SemaphoreSlim(1,1) 串行化上游解析,且闸门前读一次、闸门后再读一次缓存:并发请求排队时第一个把结果写进缓存,后面的直接命中。上游解析有独立超时(12 秒,用 CreateLinkedTokenSource 从请求 token 派生),并用 when (!ct.IsCancellationRequested) 区分「上游超时」与「客户端断开」——只有前者才降级,后者必须继续抛。

兜底——_lastKnown 保存上一次成功的地址,上游抖动时退回:

/// <summary>上一次解析成功的结果,用于上游抖动时兜底(CDN 的旧链接往往还能撑一会儿)。</summary>
private readonly ConcurrentDictionary<string, NetEaseResolvedUrl> _lastKnown = new();

这是个需要勇气的决定:明知地址可能已过期,也宁可下发给前端。因为失效的地址还有一次机会(前端会重试),而从列表里消失是必然的失败。

单例依赖作用域服务——解析器是单例(缓存与闸门必须跨请求共享),但 provider 持有 typed HttpClient 属作用域服务,于是按需 _scopes.CreateScope() 取注册表,避免 captive dependency。

2.2 快照与传输

Services/MusicCatalogService.cs 用 volatile 快照 + 15 秒 TTL + SemaphoreSlim 双检(无锁快路径命中即返回,未命中才进闸门重查一次)。

15 秒是「故意的短」:管理端每一次写操作(新增、更新、删除、批量、排序、刷新直链)都会调 InvalidateCatalog(),这 15 秒只用来兜突发流量。曲库是「写少读多」的数据,主动失效比 TTL 调优有效得多。

ETag 对 Id|Name|Src|Cover|DurationSeconds|Artist|LicenseName|SourceUrl 做哈希。Src(直链本身)参与哈希是刻意的:续期后 ETag 变化 → 浏览器重拉完整 JSON,代价是偶尔多传几 KB(几十条记录),收益是不会把过期地址缓存住。

传输只用一个端点——Program.cs:

app.MapGet("/api/music/tracks", async (...) => {
    var snapshot = await catalog.GetAsync(ct);
    http.Response.Headers.ETag = snapshot.ETag;
    http.Response.Headers.CacheControl = "public, max-age=15, must-revalidate";
    if (ifNoneMatch.Split(',').Any(tag => tag.Trim() == snapshot.ETag))
        return Results.StatusCode(StatusCodes.Status304NotModified);
    return Results.Json(payload);
}).RequireRateLimiting("music-api");

三层缓存依次生效:进程内快照(15 秒)→ HTTP max-age=15 → ETag 校验(未变则 304,连 JSON 都不传)。ifNoneMatch.Split(',') 是因为 If-None-Match 可以带多个 tag("a", "b"),只做整串相等比较会漏判。

限速用内置 AddRateLimiter(固定窗口、按 IP 分区、QueueLimit = 0——刷接口就该立刻 429):music-api 120 次/分钟、home-api 60 次/分钟,两种用途分开计避免互相挤占。这里用内置的原因是 Minimal API 端点能精确挂策略;Razor Pages 的 [EnableRateLimiting] 只能挂在页面类上,会连 GET 一起限速,那类场景适合手写计数。

2.3 存储与幂等迁移

Models/MusicTrack.cs 里两个字段是整套续期机制的锚点:Provider("netease" / "kuwo" / "archive",为空表示手工录入的直链)+ SourceKey(平台内标识,直链过期时靠它重新解析),二者成对使用,并有组合索引 IX_MusicTracks_Provider_SourceKey 支撑多音源查重。

另有一个兼容列 SourceId,注释写明「新代码一律使用 Provider + SourceKey,本列仅为回滚旧版本程序保留(仍会写入)」。只读不写的老列会造成回滚即数据丢失,双写让旧版本进程仍能工作;读取侧对应地退回用 SourceId 合成一个 netease 引用。

DatabaseInitializer.BackfillMusicTracks 是每次启动都跑的幂等修缮:

  1. http:// 直链与封面统一改写 https://——否则站点走 HTTPS 时音频会被浏览器当混合内容直接拦掉;
  2. 从老式 music.163.com/song/media/outer/url?id=<数字>.mp3 里反解出 SourceId,回填 Provider / SourceKey / SourceUrl——把「只能播放」的历史数据升级成「可续期」的数据;
  3. SortOrder 压实为 0..n-1。
第 3 条对应一个线上真实故障:新增曲目固定写 SortOrder = 0,导致全部并列、上移/下移交换相同值等于无效。修法是放弃「交换相邻两条」,改成「取出列表 → 移动元素 → 整体重新编号」,因为前者在值相等时会静默失效。

2.4 管理端

Pages/Admin/Music.cshtml.cs 近 900 行,是这套系统里最厚的文件,但职责都很具体:

端点关键点
OnGetSearchAsync回包带 renewable(是否需续期)与 inLibrary(是否已入库),前端据此决定渲染哪几个按钮
OnPostAddFromSearchAsync直链一律由服务端重新解析——前端传的 URL 一律不信任
OnGetResolveUrlAsync对 !SupportsAutoRenew 的音源直接拒绝:「直链长期有效,直接添加即可」
OnGetRefreshAsync / OnPostRefreshAllAsync单曲绕过缓存;整批合并成一次上游请求
OnGetProbeAsyncHEAD 失败(CDN 常见 405/403)退回只取 1 字节的 GET(Range: bytes=0-1);先过 SSRF 校验
OnGetPingAsync用 search("test", 1) 计时返回毫秒数,解决「搜索没反应」无从排查的问题

试听片段默认拦截:拿到试听版就拒绝入库并给出理由——「一首歌在曲库里但只有 30 秒」比「加不进去」体验更差,逃生口是显式勾选 allowTrial。

顺序一致性:NormalizeSortOrderAsync() 在新增与删除后都跑一次,保证 SortOrder 始终是稠密唯一的 0..n-1——比「插入中间值时重算」简单,代价只是一次全表写(曲库几十行)。



三、前端实现

wwwroot/js/global-music.js 约 950 行、无框架、IIFE + 'use strict'。文件头列的四条要点正是后端约束的镜像:按 id 对齐(约束 A)、进度按曲目分别记忆、失败自愈三级降级(约束 B)、先本地快照再对齐服务端(约束 C)。

3.1 状态与身份

_Layout.cshtml 里有唯一的 <audio id="globalAudio">,每页一份、翻页时被销毁重建——唯一能跨页活下来的只有 localStorage。面板开合只切 .collapsed 类,DOM 从不销毁,所以开合不丢状态、不重新加载音频。

层载体内容生命周期
内存模块闭包变量tracks、currentIndex、currentTrackId、随机队列、两个失败计数器一次页面加载
偏好globalMusicState_v2trackId、mode、volume、muted、collapsed、playing跨页跨会话
进度globalMusicProgress_v1[{id, t}],最多 60 条跨页
曲库快照globalMusicCatalog_v1{ts, items}跨页
服务端GET /api/music/tracks曲库(含直链)每次进页面同步,唯一权威

进度单独一个 key,因为 timeupdate 每 5 秒落一次盘;与模式/音量共用会让整个状态被反复重写(写放大)。所有读写都包 try/catch 且失败静默——隐私模式下 setItem 会抛配额错误,存不上音量不能崩掉播放器。readState() 是入站数据的第一个消毒点,每个字段都做类型与取值域校验,没有一个字段直接信任 JSON.parse 的结果——localStorage 既能被用户手改、又是历史版本写过的,它和 HTTP 请求体一样属于不可信输入。

身份上一切按 id 对齐:

/ 曲库、进度、当前曲目全部以 id 为准(不再用数组下标/URL 全等比对),
// 因此管理员调序、网易云直链续期(URL 变化)都不会被误判成"换歌"而重新缓冲

后端花五层代码去解决「直链会变」,绝不能在前端被一个 oldSrc === newSrc 的比较漏掉;同理,管理员一次拖拽排序会让所有下标整体位移。

normalizeItem() 是唯一的入站关口,字段逐个类型校验与缺省,src 为空直接丢弃——后端加字段、改字段,前端只需改这一个函数,而不是在十几个渲染点各改一遍。服务端还是旧进程(接口不返回 id)时,用曲名派生一个稳定的负数 id(DJB2 哈希)兜底:进度记忆、当前曲目判定、列表高亮都需要一个可比较的键,用一个不完美但稳定的代理键换回整个功能面,代价是同名曲目会碰撞(仅存在于「前端已更新、后端还是旧进程」这个窗口)。

3.2 播放核心与失败自愈

只要有「换一首歌」就必须经过 selectIndex(),而它内部的语句顺序本身就是设计:

function selectIndex(index, autoPlay, seekTo) {
    if (!tracks.length) return;

    commitProgress();      // ← 必须在改 currentTrackId 之前,否则进度记到新曲目头上
    currentIndex   = ((index % tracks.length) + tracks.length) % tracks.length;
    currentTrackId = tracks[currentIndex].id;

    pendingSeek  = typeof seekTo === 'number' && seekTo > 0 ? seekTo : null;
    errorRetried = false;  // ← 残留会让新曲目的第一次失败失去重试机会
    audio.src = tracks[currentIndex].src;
    audio.load();
    resetProgressUi(); renderTrack(); renderPlaylistActive(); saveState();
    if (autoPlay) play();
}

四个不变量:commitProgress() 必须先于身份切换(反了就把当前曲目的位置写到下一首头上);currentIndex 与 currentTrackId 必须同时更新(同一身份的两个视图,前者给渲染、后者给持久化);切换身份必须重置 pendingSeek、errorRetried;取模要处理负数——-1 % 5 在 JS 里是 -1,直接取模会得到 tracks[-1] → 崩。

pendingSeek 的消费点在 loadedmetadata 而不是设置点(只有元数据加载完才知道时长、才能判断越界),且只消费一次;目标位置若已接近结尾(at >= duration - 1)则不 seek,否则会立刻触发 ended,表现为「刚点重试就又跳下一首」。

进度落盘六个时机:切歌前、暂停、播完、拖动结束、每 5 秒(timeupdate)、页面隐藏与卸载。三个卸载事件一起挂是有意冗余——bfcache 与移动端场景下 beforeunload 常常不触发。播完归零留 3 秒容差(time >= duration - 3 则记 0),因为 ended 触发时 currentTime 未必精确等于 duration,严格判等会出现「听完的歌下次从最后 0.4 秒开始播」;进度上限 60 条 FIFO,避免无上限持久化。

失败自愈三级:

async function handlePlaybackError() {
    if (!errorRetried) {                       // ① 直链可能刚失效
        errorRetried = true;
        const at = audio.currentTime;          // 先记住位置
        const fresh = await fetchTracks(true); // 绕过 HTTP 缓存与 304 协商
        if (fresh) applyCatalog(fresh);
        const same = tracks.findIndex(t => t.id === failed.id);
        if (same >= 0 && tracks[same].src !== failed.src) {
            selectIndex(same, true, at);       // 位置保留,接着播
            return;
        }
    }
    // ② 跳下一首并提示;③ 连跳 3 首后停止自动切换
}

这不是防御性代码,而是两个子系统之间的接口约定:后端敢下发可能已过期的 _lastKnown(2.1),前提是前端会重试;前端敢牺牲位置重试,前提是后端能给出新地址。

errorRetried 在 selectIndex() 里清零(每次换歌重新获得一次机会);consecutiveFailures 在 play() 的 promise resolve、playing、ended 三处清零——三处不是冗余:自动播放被拦截时 play() 的 promise 会 reject,而 playing 事件永远不会来,只依赖它会让计数器被永久污染。

强制重取用 ?v=<时间戳> + cache: 'no-store',同时绕开浏览器 HTTP 缓存与「If-None-Match 可能被 304 挡住」的协商路径——ETag 协商在正常读取时是优化,在自愈路径上就是负担。

跨页续播靠启动顺序:

(function bootstrapFromCache() { /* 1) 同步读本地快照,先接上 audio.src */ })();
syncCatalog();                     // 2) 再与服务端对齐(命中 ETag 直接 304)

bootstrapFromCache() 是同步 IIFE,不 await 任何东西,audio.src 在第一帧之前就设好了,此时 GET /api/music/tracks 可能还没发出去——先满足体感、再满足正确性。另外 bfcache 恢复时 JS 完全不会重新执行,只能靠 pageshow + e.persisted 补一次同步。

applyCatalog() 的 sameShape 只比较 id / name / cover / artist / duration,刻意不比较 src:否则每次直链续期(网易云 TTL 最长 15 分钟,是常态)都会重建列表,代价是一闪一闪的列表和被顶掉的滚动位置。当前曲目被删且正在播时让它播完再提示切换,而不是突然掐掉。

3.3 渲染、事件与缓存边界

渲染分四档,不用同一种更新方式:只改数值(updateProgressUi,给每秒数次的 timeupdate)、局部刷新(renderTrack 等,给换歌与状态变化)、全量重建(renderPlaylist,用 DocumentFragment 一次性挂载,仅曲库形状变化)、只切类名(renderPlaylistActive,当前曲目变化不重建)。另外图片只在 URL 变化时赋值——同一地址重复赋值会让浏览器重新请求、闪一下。

页面与播放器只通过 CustomEvent 通信:globalmusic:cmd 向内(toggle/play/pause/prev/next/playId,可带 expand),state / track / duration 向外,另有 window.globalMusicPlaying 供「只关心在不在播」的按钮使用。不用全局函数是因为两个脚本都是 defer 加载,执行顺序不可假设——事件是「发送即忘」,监听者还没注册时只是没人响应,不会抛错;函数调用则必须再加一层「播放器就绪」的握手。

Service Worker 把静态资源放进 stale-while-revalidate(改前端不用升缓存版本号),但:

// 动态 API 永不缓存: 走网络, 保证曲库等数据实时 (此前 /api/music/tracks 被缓存导致新增曲目不生效)
if (url.pathname.startsWith('/api/')) return;

这句注释记录了一次真实的线上事故:曲库接口被 SW 缓存后,「新增曲目」在用户侧永远不出现。而它同时是整套续期机制成立的前提——接口一旦被缓存住,前端拿到的永远是旧直链,失败自愈会退化成「反复重试同一个坏地址」。整条链是:SW 不缓存 /api/ → 每次进页面都能拿到新直链 → 直链过期时重取才有意义 → 后端才敢下发可能过期的旧链。任意一环被「优化」掉(比如为了省流量把曲库缓存 5 分钟),自愈链就断——这是最容易在后续维护中被改坏的地方。

另外两处实现细节:单曲循环不用 audio.loop,而是在 ended 里显式重播——loop 生效时 ended 根本不触发,「播完归零进度、清失败计数」就执行不到;随机播放不是随机取下一首,而是预生成乱序队列(Fisher–Yates)加历史栈,否则「上一首」回不到真正的上一首。



四、全链路与关键决策

4.1 一次播放的完整生命周期

用户打开页面
   ├─ ① JS 同步读本地曲库快照 → 若有当前曲目,先把 audio.src 接上(不等网络)
   ├─ ② GET /api/music/tracks
   │     限速 music-api 120 次/分钟 → 快照 15 秒内直接返回(无锁快路径)
   │     冷/过期 → 取闸门 → 双检 → BuildAsync
   │       ├─ 查库(AsNoTracking,只取 IsActive)
   │       ├─ 挑出 NeedsRenewal 的曲目(archive 直接被过滤掉)
   │       ├─ 逐条读直链缓存;未命中 → 闸门 → 二次读缓存 → 按 Provider 分组
   │       │    ├─ netease:3 档音质 × 每档一次批量请求
   │       │    └─ kuwo:并发度 4 的单曲解析
   │       ├─ 写正缓存(动态 TTL)/ 写负缓存 / 失败退回 _lastKnown
   │       └─ 组装 items + 计算 ETag
   │     命中 If-None-Match → 304(连 JSON 都不传)
   ├─ ③ JS applyCatalog():列表内容(不含直链)没变则不重建 DOM;
   │      当前曲目仍在则保持身份并恢复进度;被删且正在播则让它播完
   └─ ④ 播放中:直链失效 → 强制重取 → 位置续播 → 仍失败则跳下一首(连跳 3 首停止)
         关闭/切页 → commitProgress() 落盘 + saveState()

   ⓘ 前提:/api/* 永不缓存(否则 ④ 的第一级自愈直接失效)
           bfcache 恢复时 JS 不重新执行 → 靠 pageshow + e.persisted 补一次 syncCatalog()

4.2 关键决策速查表

决策常见替代为什么不用
曲库只存来源标识,直链每次现解析存解析出的直链,过期再更新存了也一定会过期,「什么时候过期」不可知(网易云 expi 会变、酷我按会话签发)
TTL 跟随上游 expi固定 15 分钟 / 1 小时固定值可能长于有效期,下发出去就是坏链
负缓存分两档(40 秒 / 10 分钟)统一一个时长「无版权」和「网络抖动」的正确反应相反
解析失败退回上一次的地址让曲目从列表消失失效地址还有一次机会(前端会重试),消失是必然失败
ETag 包含直链 Src只对「曲库内容」算哈希否则续期后 ETag 不变,浏览器继续用缓存里的旧地址
手写网易云/酷我客户端第三方 SDK / Node 版 music-api 服务只需要 2 个 HTTP 接口;加密接口已被风控(50000005),不实现加密是有实测依据的简化
进程内缓存 + 单把闸门Redis / IDistributedCache单实例个人站,省掉整个分布式层
SourceId 老列继续双写直接删掉老列回滚旧版本进程时数据仍在
SortOrder 整体重编号交换相邻两条的值值相等时交换等于没动(线上真发生过)
管理端入库前先服务端解析信任前端传来的直链前端参数一律不可信
默认拦截试听片段允许入库,用户自己发现只有 30 秒「在曲库里但只有 30 秒」比「加不进去」更差
前端按曲目 id 对齐用下标或 URL 全等判断「是不是同一首」直链会变(后端全部工作就是在处理这件事);下标会因一次拖拽排序整体位移
进度单独一个 localStorage 键与音量 / 模式共用状态对象timeupdate 每 5 秒写一次,共用会让整个状态被反复重写
用 CustomEvent 而不是全局函数window.globalMusic.playId(id)两个脚本都是 defer,执行顺序不可假设
单曲循环不用 audio.loop设置 loop = trueloop 生效时 ended 不触发,播完归零就执行不到
随机播放用预生成队列 + 历史栈每次随机取一首纯随机下「上一首」回不到真正的上一首


返回文章列表
admin

发表评论 · 访客

评论 (0)

才,才不想让你评论呢~