一、问题与架构
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 是每次启动都跑的幂等修缮:
http://直链与封面统一改写https://——否则站点走 HTTPS 时音频会被浏览器当混合内容直接拦掉;- 从老式
music.163.com/song/media/outer/url?id=<数字>.mp3里反解出SourceId,回填Provider/SourceKey/SourceUrl——把「只能播放」的历史数据升级成「可续期」的数据; SortOrder压实为0..n-1。
第 3 条对应一个线上真实故障:新增曲目固定写 SortOrder = 0,导致全部并列、上移/下移交换相同值等于无效。修法是放弃「交换相邻两条」,改成「取出列表 → 移动元素 → 整体重新编号」,因为前者在值相等时会静默失效。
2.4 管理端
Pages/Admin/Music.cshtml.cs 近 900 行,是这套系统里最厚的文件,但职责都很具体:
| 端点 | 关键点 |
| OnGetSearchAsync | 回包带 renewable(是否需续期)与 inLibrary(是否已入库),前端据此决定渲染哪几个按钮 |
| OnPostAddFromSearchAsync | 直链一律由服务端重新解析——前端传的 URL 一律不信任 |
| OnGetResolveUrlAsync | 对 !SupportsAutoRenew 的音源直接拒绝:「直链长期有效,直接添加即可」 |
| OnGetRefreshAsync / OnPostRefreshAllAsync | 单曲绕过缓存;整批合并成一次上游请求 |
| OnGetProbeAsync | HEAD 失败(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_v2 | trackId、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 = true | loop 生效时 ended 不触发,播完归零就执行不到 |
| 随机播放用预生成队列 + 历史栈 | 每次随机取一首 | 纯随机下「上一首」回不到真正的上一首 |