feat(ncm): 封面死链兜底链 —— 原 picUrl 失败→web 端 song/detail 重取活链→歌单封面兜底

用户调研: 官方旧版 GET music.163.com/api/song/detail/?id=<id>&ids=[<id>] 免登录出
songs[0].album.picUrl/blurPicUrl。本批宿主实锤: 登录态 eapi 给的死链封面, 该 web 接口给不同活链
(死链 yR2VGt7.../10995116862531 -> album.picUrl p2.../6y-UleORIT.../...jpg 活 PNG 146KB)。

实现 (patch_v2/ncm_app/):
- cover_request(url, song_id): 新增第二参曲目 id; g_tgt_song 记录; 新目标重置 g_fb_done。
  player.c 三处传 s.id(专辑封面), playlist.c 传 NULL(歌单封面无兜底)。
- cover_song_detail_pic(song_id): web song/detail 重取 album.picUrl(缺 blurPicUrl);
  ids=[<id>] 的 [ ] URL 编码 %5B/%5D(裸[ ] curl 报 bad range); 带 Referer+桌面UA; ncm_http_request
  (头进 resp->headers, 体进 16KB 栈缓冲) + ncm_json 解析; 只读一次。
- cover_try_fallback(url, song_id): 仅原链确定性失败时触发; g_tgt_url 仍==url(未被换走) + 曲目非空 +
  未兜底 + 同曲 5 分钟冷却(ncm_monotonic_ms) 才下探; 拿到不同新图->记冷却/置 g_fb_done/换目标返 1
  (worker 重试新图); 无新图/冷却中/已兜底/目标被换走->返 0 走歌单封面兜底; 每步日志写清级别。
- worker: kind=1(webp)/kind=2(非图)/确定性4xx 死链分支清目标前先
  if(song[0]&&!fb_done&&cover_try_fallback(url,song)) continue; 瞬时错误仍指数退避不触发。
- 风控: 只在原链失败触发(不新增常规请求); 单曲目最多一次(g_fb_done)+5分钟冷却。

验证 (宿主+qemu, 0 新增真实 CDN 业务请求):
- cover_chain_sim T1-T12 ALL PASS(新增 T11 原链死->song/detail活链->出图+恰1次+song-detail fallback日志;
  T12 兜底也死->留歌单封面+同曲再请求 song/detail 0次(冷却)+cooldown日志)。
- cache_evict/player_flow/window/search/stream sim 全 PASS; core host+mipsel(qemu) PASS;
  build_ncm.sh 0 warning(text 1022210/bss 1362564,+64B); ncm_host UI dump exit=0。
  新 ncm md5 e1f4b7ade834b18c7910b9c3e64bc858 (未部署, 留主会话上卡)。
This commit is contained in:
AskaEth committed 2026-09-02 11:41:54 +08:00
1 parent 9d93f1b1ef
commit 2bfe1ed796
8 files changed
+261 -20

No files matched your search

+75 -11
View File
@@ -49,6 +49,16 @@ size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
while (src[n]) n++;
return n;
}
size_t ncm_strlcat(char *dst, const char *src, size_t sz)
{
size_t dn = strlen(dst), sn = 0;
while (dn + sn + 1 < sz && src[sn]) { dst[dn + sn] = src[sn]; sn++; }
if (sz) dst[dn + sn] = 0;
return dn + sn;
}
int ncm_streq(const char *a, const char *b) { return a && b && strcmp(a, b) == 0; }
int ncm_parse_int(const char *s, long *out) { char *e; long v = strtol(s, &e, 10); if (e == s || *e) return -1; if (out) *out = v; return 0; }
int64_t ncm_monotonic_ms(void) { static int64_t t = 0; return ++t; } /* 单调递增:冷却时差恒 <5min(T12 冷却判定用) */
static int g_log_warns;
static char g_last_log[512]; /* 最近一条日志文本(webp/403 分诊日志断言用) */
@@ -113,13 +123,16 @@ void meow_img_free(meow_img *im)
/* §18 前的 cover.c 无 cover_put:weak 空实现让同一 sim 可对照旧代码(新代码强符号覆盖)。 */
__attribute__((weak)) void cover_put(void) { }
/* ---- HTTP stub:内容 = djb2(url) 填充 4KB(每 URL 唯一);可注入失败/延迟/webp/403 ---- */
/* ---- HTTP stub:内容 = djb2(url) 填充 4KB(每 URL 唯一);可注入 失败/延迟/webp/json/403/死链前缀 ---- */
static int g_http_calls;
static int g_sd_calls; /* song/detail 请求计数(死链兜底专用,REPORT §NEW 任务1) */
static int g_fail_next; /* 剩余注入失败次数 */
static int g_delay_ms; /* 模拟慢网络 */
static int g_webp_next; /* 下一次下载返回 WebP 魔数体(200,测「200 解码失败」三分型③) */
static int g_json_next; /* 下一次下载返回 JSON 错误体(200,实测「200 解码失败」真凶=死对象回 JSON) */
static int g_status_next; /* 下一次下载强制返回该 HTTP status(测 403 防盗链;0=200) */
static char g_dead_prefix[128] = ""; /* URL 此前缀 → 返 404(死链注入;兜底链测试用) */
static char g_alt_pic[256] = ""; /* song/detail 响应里给的新 picUrl(兜底链活链/死链用) */
static unsigned long djb2(const char *s)
{
@@ -133,6 +146,23 @@ int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_ht
g_http_calls++;
if (g_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; }
if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000);
/* 死链兜底:song/detail 请求 → 返 JSON(songs[0].album.picUrl = g_alt_pic),供 cover.c
* cover_song_detail_pic 重取新图(REPORT §NEW 任务1)。 */
if (strstr(req->url, "/api/song/detail/")) {
g_sd_calls++;
int n = snprintf(buf, buf_sz,
"{\"songs\":[{\"album\":{\"picUrl\":\"%s\",\"blurPicUrl\":\"\"}}],\"code\":200}",
g_alt_pic);
if (n < 0 || (size_t)n >= buf_sz) return NCM_ERR_FULL;
memset(resp, 0, sizeof *resp);
resp->status = 200;
resp->body = buf;
resp->body_len = (size_t)n;
resp->content_length = (size_t)n;
resp->has_content_type = 1;
ncm_strlcpy(resp->content_type, "application/json", sizeof resp->content_type);
return NCM_OK;
}
size_t n = 4096 < buf_sz ? 4096 : buf_sz;
unsigned long h = djb2(req->url);
for (size_t i = 0; i < n; i++) buf[i] = (char)((h >> ((i % 8) * 8)) ^ (unsigned char)i);
@@ -155,6 +185,12 @@ int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx
{
(void)max_bytes;
g_http_calls++;
/* 死链注入:URL 前缀命中 → 返 404 无 body(cover.c 判确定性 4xx,走 song/detail 兜底) */
if (g_dead_prefix[0] && strncmp(req->url, g_dead_prefix, strlen(g_dead_prefix)) == 0) {
memset(resp, 0, sizeof *resp);
resp->status = 404;
return NCM_OK;
}
if (g_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; }
if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000);
unsigned char buf[4096];
@@ -253,6 +289,9 @@ static unsigned long curjpg_hash(void)
#define URL_E "http://p1.music.126.net/albumE/vvv.jpg" /* webp 注入用(未缓存) */
#define URL_F "http://p1.music.126.net/albumF/uuu.jpg" /* 403 注入用(未缓存) */
#define URL_G "http://p1.music.126.net/albumG/ttt.jpg" /* JSON 错误体注入用(未缓存) */
#define URL_H "http://p1.music.126.net/dead/album/AAA.jpg" /* 死链原图(song/detail 兜底测试用) */
#define URL_H2 "http://p1.music.126.net/dead/album/BBB.jpg" /* 兜底给到的也死链 alt */
#define URL_LIVE "http://p1.music.126.net/live/SONG.jpg" /* 兜底给到的活链 alt */
int main(void)
{
@@ -262,7 +301,7 @@ int main(void)
ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir);
printf("== T1: first request -> cover appears ==\n");
cover_request(URL_A);
cover_request(URL_A, NULL);
CHECK(wait_cover_hash(url_hash(URL_A), 3000), "cover A shown (hash match)");
CHECK(g_http_calls == 1, "http calls=%d (expect 1)", g_http_calls);
CHECK(curjpg_hash() == url_hash(URL_A), "cover_current.jpg == A");
@@ -275,7 +314,7 @@ int main(void)
CHECK(g_http_calls == 1, "http calls=%d (still 1)", g_http_calls);
printf("== T3: switch song -> cover switches + cover_current.jpg updated ==\n");
cover_request(URL_B);
cover_request(URL_B, NULL);
CHECK(wait_cover_hash(url_hash(URL_B), 3000), "cover switched to B");
CHECK(curjpg_hash() == url_hash(URL_B), "cover_current.jpg == B");
CHECK(g_http_calls == 2, "http calls=%d (expect 2)", g_http_calls);
@@ -284,13 +323,13 @@ int main(void)
CHECK(!cover_changed(), "no churn after switch");
printf("== T4: re-request same url -> dedup ==\n");
cover_request(URL_B);
cover_request(URL_B, NULL);
usleep(500 * 1000);
CHECK(g_http_calls == 2, "http calls=%d (still 2, dedup)", g_http_calls);
printf("== T5: flaky network x2 -> backoff retry succeeds ==\n");
g_fail_next = 2;
cover_request(URL_C);
cover_request(URL_C, NULL);
CHECK(wait_cover_hash(url_hash(URL_C), 8000), "cover C after retries");
CHECK(g_http_calls >= 5, "http calls=%d (>=5: 2 fails + 1 ok for C)", g_http_calls);
CHECK(g_log_warns >= 2, "warn logs=%d (failures logged, 不无声)", g_log_warns);
@@ -298,7 +337,7 @@ int main(void)
printf("== T6: permanent failure -> give up after %d tries ==\n", 8);
g_fail_next = 100;
int before = g_http_calls;
cover_request(URL_D);
cover_request(URL_D, NULL);
usleep(25 * 1000 * 1000 / 10); /* ~2.5s 不够 8 次退避(0.5+1+2+4*5=23.5s),分段等 */
for (int t = 0; t < 30 && g_http_calls - before < 8; t++) usleep(1000 * 1000);
int tries = g_http_calls - before;
@@ -312,7 +351,7 @@ int main(void)
g_fail_next = 0;
int b8 = g_http_calls;
g_webp_next = 1;
cover_request(URL_E);
cover_request(URL_E, NULL);
usleep(800 * 1000); /* 等 worker 处理 webp 体 → 判 webp → 清目标 → 转怠速 */
CHECK(g_http_calls == b8 + 1, "webp: single download=%d (no retry)", g_http_calls - b8);
CHECK(cur_cover_hash() == url_hash(URL_C), "webp: 旧封面 C 保留(兜底,不误换)");
@@ -321,7 +360,7 @@ int main(void)
printf("== T9: HTTP 403 anti-hotlink -> single download, no retry, distinct log ==\n");
int b9 = g_http_calls;
g_status_next = 403;
cover_request(URL_F);
cover_request(URL_F, NULL);
usleep(800 * 1000);
CHECK(g_http_calls == b9 + 1, "403: single download=%d (no retry)", g_http_calls - b9);
CHECK(cur_cover_hash() == url_hash(URL_C), "403: 旧封面 C 保留(兜底)");
@@ -331,16 +370,41 @@ int main(void)
printf("== T10: 200+JSON error body (dead origin) -> not an image, no retry ==\n");
int b10 = g_http_calls;
g_json_next = 1;
cover_request(URL_G);
cover_request(URL_G, NULL);
usleep(800 * 1000);
CHECK(g_http_calls == b10 + 1, "json: single download=%d (no retry)", g_http_calls - b10);
CHECK(cur_cover_hash() == url_hash(URL_C), "json: 旧封面 C 保留(兜底)");
CHECK(strstr(g_last_log, "not an image") != NULL, "log: 'not an image' seen");
printf("== T11: 原链死 -> song/detail 兜底活链 -> 出图 ==\n");
g_fail_next = 0; g_json_next = 0; g_webp_next = 0; g_status_next = 0;
ncm_strlcpy(g_dead_prefix, "http://p1.music.126.net/dead/", sizeof g_dead_prefix);
ncm_strlcpy(g_alt_pic, URL_LIVE, sizeof g_alt_pic);
int sd11 = g_sd_calls;
cover_request(URL_H, "90001");
CHECK(wait_cover_hash(url_hash(URL_LIVE), 6000), "cover shown via song/detail live pic");
CHECK(g_sd_calls == sd11 + 1, "song/detail called once (sd=%d)", g_sd_calls - sd11);
CHECK(cur_cover_hash() == url_hash(URL_LIVE), "cover == song/detail live pic");
CHECK(strstr(g_last_log, "song-detail fallback id=90001") != NULL, "log: song-detail fallback seen");
printf("== T12: 原链死 -> 兜底也死 -> 歌单封面兜底 + 冷却不重试 ==\n");
ncm_strlcpy(g_alt_pic, URL_H2, sizeof g_alt_pic); /* 兜底也给一个死链 URL */
int sd12 = g_sd_calls;
cover_request(URL_H, "90002");
usleep(1500 * 1000); /* 等:URL_H 死 -> song/detail(90002)->URL_H2 -> URL_H2 也 404 -> 清目标 */
CHECK(g_sd_calls == sd12 + 1, "T12 song/detail once (sd=%d)", g_sd_calls - sd12);
CHECK(cur_cover_hash() == url_hash(URL_LIVE), "T12 兜底也死:保留上个封面(URL_LIVE)");
int sd13 = g_sd_calls;
cover_request(URL_H, "90002"); /* 同曲 90002 再请求 → 冷却,不再 song/detail */
usleep(1500 * 1000);
CHECK(g_sd_calls == sd13, "cooldown: no 2nd song/detail (sd=%d)", g_sd_calls - sd13);
CHECK(strstr(g_last_log, "song-detail fallback cooldown") != NULL, "log: cooldown seen");
g_dead_prefix[0] = 0; g_alt_pic[0] = 0;
printf("== T7: null/empty + reset ==\n");
int b7 = g_http_calls;
cover_request(NULL);
cover_request("");
cover_request(NULL, NULL);
cover_request("", NULL);
CHECK(g_http_calls == b7, "null/empty ignored (calls=%d)", g_http_calls);
cover_cache_reset();
CHECK(cur_cover_hash() == 0, "reset -> cover cleared");