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

+100 -4
View File
@@ -45,6 +45,10 @@ static pthread_mutex_t g_cm = PTHREAD_MUTEX_INITIALIZER;
static meow_img *g_cover_img;
static char g_cover_url[512]; /* g_cover_img 对应的 URL */
static char g_tgt_url[512]; /* 目标 URL(worker 拉取) */
static char g_tgt_song[24]; /* 目标对应曲目 id(无=歌单封面,不走 song/detail 兜底) */
static char g_fb_song[24]; /* 最近一次 song/detail 兜底的曲目 id(冷却判定) */
static int64_t g_fb_ms; /* 最近一次兜底时刻(单调毫秒;冷却) */
static int g_fb_done; /* 当前目标已尝试过 song/detail 兜底(防二次) */
static pthread_t g_thr;
static int g_thr_on;
static int g_quit;
@@ -52,6 +56,15 @@ static volatile int g_gen; /* 每次换入 +1 */
static int g_acked; /* UI 已消费的版本 */
static char g_cover_path[512]; /* 封面原始文件路径(供 launcher) */
/* 死链兜底链(REPORT §NEW 任务1):原 picUrl 确定性失败(404/403/200+非图/webp)时,自动调
* web 端 `GET music.163.com/api/song/detail/?id=<id>&ids=[<id>]`(免登录,songs[0].album.picUrl/
* blurPicUrl)重取 picUrl——不同接口指向不同 CDN 对象,可能给出**活链**(本批宿主实测坐实:
* 死链 p4.../yR2VGt7F1I9ElOzvXYuw1g==/10995116862531,而 song/detail album.picUrl 给
* p2.../6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg 活 PNG / blurPicUrl 活 JPEG)。
* 链:原 picUrl → song/detail 重取 → 歌单封面兜底。只对带曲目 id 的专辑封面触发;单曲目冷却
* COVER_FB_COOLDOWN_MS 内不重复。风控:仅在原链失败时触发,不新增常规请求。 */
#define COVER_FB_COOLDOWN_MS (5 * 60 * 1000) /* 同曲目 5 分钟冷却,避免反复撞同一死链接口 */
/* 封面缓存键:URL 的 djb2 哈希(稳定性由 URL 决定——同一专辑封面 URL 名即可命中)。
* 走 ncm_cache(LRU 口径沿用 ncm_cache,见 REPORT §14.2):键进 ncm_cache_path => 文件
* `song_cov<hex>.jpg`,与 mp3(song_<id>.mp3)同池,lookup/commit 刷新 mtime(LRU)。 */
@@ -150,6 +163,76 @@ static int body_is_decodable_img(const char *path, const ncm_http_resp_t *resp)
return 0;
}
/* Web 端 song/detail 免登录重取专辑封面 picUrl(REPORT §NEW 任务1):
* `GET https://music.163.com/api/song/detail/?id=<id>&ids=[<id>]`,取 songs[0].album.picUrl
* (缺则 blurPicUrl)。返回 0 且 pic[0]!=0 视为成功;否则负错误码。只读一次,带 Referer+桌面 UA。 */
#define COVER_SD_BUF 16384
static int cover_song_detail_pic(const char *song_id, char *pic, size_t pic_sz)
{
if (!song_id || !song_id[0]) return NCM_ERR_ARG;
if (pic_sz) pic[0] = 0;
/* ids=[<id>] 的 '[ ]' 需 URL 编码为 %5B/%5D(宿主 curl 实测:裸 [ ] 报 bad range) */
char url[352];
snprintf(url, sizeof url, "https://music.163.com/api/song/detail/?id=%s&ids=%%5B%s%%5D",
song_id, song_id);
char hdrs[256];
snprintf(hdrs, sizeof hdrs, "Referer: %s\r\nUser-Agent: %s", NCM_COVER_REFERER, NCM_COVER_UA);
ncm_http_req_t req; memset(&req, 0, sizeof req);
req.url = url; req.method = "GET"; req.extra_hdrs = hdrs;
req.verify_ca = NCM_HTTP_VERIFY_DEFAULT; req.timeout_ms = 15000;
char buf[COVER_SD_BUF];
ncm_http_resp_t resp;
int rc = ncm_http_request(&req, buf, sizeof buf, &resp);
if (rc != NCM_OK || resp.status != 200 || resp.body_len == 0)
return rc != NCM_OK ? rc : NCM_ERR_IO;
jsmntok_t toks[256]; int parent[256];
ncm_json_t j; memset(&j, 0, sizeof j);
if (ncm_json_parse(&j, resp.body, resp.body_len, toks, 256, parent) < 0)
return NCM_ERR_JSON;
if (ncm_json_path_str(&j, "songs[0].album.picUrl", pic, pic_sz) && pic[0]) return NCM_OK;
if (ncm_json_path_str(&j, "songs[0].album.blurPicUrl", pic, pic_sz) && pic[0]) return NCM_OK;
return NCM_ERR_NOTFOUND;
}
/* 死链兜底(REPORT §NEW 任务1):歌单封面兜底前,试 song/detail 重取一个新图 URL。
* 仅当 g_tgt_url 仍 == url(目标未被 UI 换走)、曲目非空、本 incident 尚未兜底、且不在同曲冷却内才触发。
* 命中且新图 != 旧图 → 换 g_tgt_url 为新图、记冷却、置 g_fb_done,返回 1(worker 重试新图);
* 无新图/冷却中/目标被换走/已兜底 → 返回 0(走歌单封面兜底)。每步打日志写清走到哪一级。 */
static int cover_try_fallback(const char *url, const char *song_id)
{
if (!song_id || !song_id[0]) return 0;
int64_t now = ncm_monotonic_ms();
int cooled = (g_fb_song[0] && strcmp(g_fb_song, song_id) == 0 &&
(now - g_fb_ms) < COVER_FB_COOLDOWN_MS);
if (cooled) {
meow_log(MEOW_LOG_WARN, "cover: song-detail fallback cooldown id=%s -> playlist cover", song_id);
return 0;
}
char pic[512] = "";
if (cover_song_detail_pic(song_id, pic, sizeof pic) != NCM_OK || !pic[0]) {
/* 无活图:仍记冷却(同曲 5 分钟内不再撞同一死链接口),落歌单封面 */
pthread_mutex_lock(&g_cm);
ncm_strlcpy(g_fb_song, song_id, sizeof g_fb_song); g_fb_ms = now;
pthread_mutex_unlock(&g_cm);
meow_log(MEOW_LOG_WARN, "cover: song-detail id=%s no live pic -> playlist cover", song_id);
return 0;
}
char nurl[512]; cover_norm_url(pic, nurl, sizeof nurl);
pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) != 0) { pthread_mutex_unlock(&g_cm); return 0; } /* 目标已被换走 */
if (nurl[0] && strcmp(nurl, g_tgt_url) != 0) {
ncm_strlcpy(g_tgt_url, nurl, sizeof g_tgt_url);
ncm_strlcpy(g_fb_song, song_id, sizeof g_fb_song); g_fb_ms = now;
g_fb_done = 1;
pthread_mutex_unlock(&g_cm);
meow_log(MEOW_LOG_INFO, "cover: song-detail fallback id=%s -> %s", song_id, pic);
return 1;
}
pthread_mutex_unlock(&g_cm);
meow_log(MEOW_LOG_WARN, "cover: song-detail id=%s yields empty/same pic -> playlist cover", song_id);
return 0;
}
/* 下载 url 到 cache 写者 w(流式,写进其临时文件),成功后从临时文件解码并返回 img。
* 失败返回 NULL(不 commit,调用方 abort)。*out_len=body 字节数;*out_rc 与 *out_status
* 回填下载 rc 与 HTTP status(封面 4xx 加固判定:确定性 4xx 不重试,REPORT §NEW 任务3);
@@ -246,8 +329,11 @@ static void *cover_worker(void *arg)
usleep(250 * 1000);
continue;
}
char song[24];
char url[512];
ncm_strlcpy(url, g_tgt_url, sizeof url);
ncm_strlcpy(song, g_tgt_song, sizeof song);
int fb_done = g_fb_done; /* 本目标是否已做过 song/detail 兜底(防二次) */
pthread_mutex_unlock(&g_cm);
char key[32];
@@ -296,9 +382,10 @@ static void *cover_worker(void *arg)
* - 仅瞬时错误(超时/网络/TLS/5xx)指数退避重试。
* 日志一律打 rc/status(排查资产:先前只见 "fetch failed" 不知 HTTP 层返了什么);
* 403 单独日志(防盗链特征,与 404 死链区分——三者修法不同)。 */
if (dkind == 1) { /* webp:200 但 stb 不支持解码,非瞬时非重试可愈 → 立即清目标走兜底 */
if (dkind == 1) { /* webp:200 但 stb 不支持解码,非瞬时非重试可愈 → 兜底或清目标 */
meow_log(MEOW_LOG_WARN, "cover: webp unsupported url=%.64s rc=%d status=%d (no decode)",
url, drc, dstatus);
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张(走兜底) */
pthread_mutex_unlock(&g_cm);
@@ -307,9 +394,10 @@ static void *cover_worker(void *arg)
}
if (dkind == 2) { /* 200 但 body 非可解码图(源站死对象回 JSON 错误体/未知格式):
* 真机矩阵(REPORT §34.1)确认为「200 解码失败」主因,老逻辑空转 8 次;
* 确定性失败(重试救不了死对象)→ 立即清目标走兜底 */
* 确定性失败(重试救不了死对象)→ 兜底或清目标 */
meow_log(MEOW_LOG_WARN, "cover: not an image (origin error body/200) url=%.64s rc=%d status=%d (no retry)",
url, drc, dstatus);
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0;
pthread_mutex_unlock(&g_cm);
@@ -328,6 +416,7 @@ static void *cover_worker(void *arg)
url, drc, dstatus);
}
if (deterministic) {
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张 */
pthread_mutex_unlock(&g_cm);
@@ -370,10 +459,11 @@ static void *cover_worker(void *arg)
return NULL;
}
/* UI 线程:提交目标封面 URL(无网络/无 URL 则忽略)。
/* UI 线程:提交目标封面 URL(无网络/无 URL 则忽略)。song_id:曲目 id(专辑封面死链时走
* song/detail 兜底重取,见 REPORT §NEW 任务1);NULL/空=歌单封面(无兜底)。
* 先 cover_norm_url 规范化(追加 ?param=300y300 缩略后缀,REPORT §NEW 任务2),使
* g_tgt_url/g_cover_url/缓存键/下载 URL 走同一参数版本(小图省内存、命中高)。 */
void cover_request(const char *url)
void cover_request(const char *url, const char *song_id)
{
if (!url || !url[0]) return;
if (g_host_mock) return; /* 宿主 mock = 0 网络纪律(mock URL 不得发真实 HTTPS) */
@@ -385,6 +475,8 @@ void cover_request(const char *url)
if ((g_cover_url[0] && strcmp(g_cover_url, u) == 0) ||
(g_tgt_url[0] && strcmp(g_tgt_url, u) == 0)) { pthread_mutex_unlock(&g_cm); return; }
ncm_strlcpy(g_tgt_url, u, sizeof g_tgt_url);
ncm_strlcpy(g_tgt_song, song_id ? song_id : "", sizeof g_tgt_song);
g_fb_done = 0; /* 新目标:重置「是否已兜底」标记 */
pthread_mutex_unlock(&g_cm);
if (!g_thr_on) {
g_thr_on = 1;
@@ -429,6 +521,10 @@ void cover_cache_reset(void)
if (g_cover_img) { meow_img_free(g_cover_img); g_cover_img = NULL; }
g_cover_url[0] = 0;
g_tgt_url[0] = 0;
g_tgt_song[0] = 0;
g_fb_song[0] = 0;
g_fb_ms = 0;
g_fb_done = 0;
g_cover_path[0] = 0;
pthread_mutex_unlock(&g_cm);
g_gen++;