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

+81
View File
@@ -3106,3 +3106,84 @@ cb8187868f71a9fa155075d0624711d6 ncm # mipsel 静态 App(text 101711
另立批加明文 80 路径。 另立批加明文 80 路径。
- **真机封面加载成功率提升未实测**(本批未部署——留主会话上卡后播放几首观察 `cover:` 日志,预期 404/403/ - **真机封面加载成功率提升未实测**(本批未部署——留主会话上卡后播放几首观察 `cover:` 日志,预期 404/403/
非图幂等快速跳下一张+歌单封面兜底,不再 8 次空转)。 非图幂等快速跳下一张+歌单封面兜底,不再 8 次空转)。
---
## 35. 封面死链兜底链:原 picUrl → song/detail 重取 → 歌单封面兜底(2026-09-02)
> 用户调研:官方旧版 `GET music.163.com/api/song/detail/?id=<id>&ids=[<id>]` **免登录**直接出 JSON,
> `songs[0].album.picUrl`(还有 blurPicUrl)。价值:登录态 eapi 给的 picUrl 死链时,这个 web 端接口
> 可能给出**不同活链**(不同接口指向不同 CDN 对象)。本批宿主实锤:死链 `p4.music.126.net/yR2VGt7.../10995116862531`
> 的 song 经 web 接口返回 `album.picUrl = p2.music.126.net/6y-UleORIT.../5639395138885805.jpg`(活 PNG 146KB)
> 与 `blurPicUrl`(活 JPEG 1.34MB 全尺寸)——兜底价值坐实。
### 35.1 兜底链实现(`app/logic/cover.c` + `app/ui/player.c` + `app/ui/playlist.c` + `app/ncm_app_internal.h`)
**链路**:原 picUrl(确定性 404/403/200+非图/webp)→ 【下探】song/detail 重取新 picUrl → 与原 URL 不同则
再拉一次(仍走 Referer+桌面 UA+`?param`+魔数判定)→ 仍失败/无活图 → **歌单封面兜底**(保留当前显示)。
- **`cover_request(url, song_id)`**:新增第二参 `song_id`(曲目专辑封面传 `s.id`;歌单封面传 NULL)。
`g_tgt_song` 记录当前目标曲目;新目标重置 `g_fb_done=0`。player.c 三处传 `s.id`,playlist.c 传 NULL。
- **`cover_song_detail_pic(song_id, pic)`**(新增):`GET music.163.com/api/song/detail/?id=<id>&ids=[<id>]`
(`[ ]` URL 编码为 `%5B %5D`——宿主 curl 实测裸 `[ ]` 报 bad range),带 Referer+桌面 UA(同封面头),
`ncm_http_request`(头进 resp->headers、体进 16KB 栈缓冲)→ `ncm_json` 取 `songs[0].album.picUrl`,
缺则 `blurPicUrl`。只读一次。
- **`cover_try_fallback(url, song_id)`**(新增):仅当 `g_tgt_url` 仍 == url(目标未被 UI 换走)、曲目非空、
尚未兜底、且**同曲 5 分钟冷却**(`g_fb_song`/`g_fb_ms`,`ncm_monotonic_ms`)未命中才触发;拿到新图且
与旧不同 → 记冷却、置 `g_fb_done=1`、换 `g_tgt_url` 为新图,返回 1(worker 重试新图);无新图/冷却中/
目标被换走/已兜底 → 返回 0(走歌单封面兜底)。每步打日志写清级别(`song-detail fallback id=.. -> url` /
`song-detail id=.. no live pic -> playlist cover` / `song-detail fallback cooldown id=.. -> playlist cover`)。
- **worker 接线**:kind=1(webp)/kind=2(非图)/确定性 4xx 四处死链分支,清目标前先
`if (song[0] && !fb_done && cover_try_fallback(url, song)) { continue; }`——命中即重试新图;
否则照旧清目标走歌单封面兜底。瞬时错误(网络/TLS/5xx)仍指数退避,不触发兜底(非死链)。
- **风控**:song/detail 只在原链失败触发(不新增常规请求),单曲目冷却 5 分钟、最多一次(`g_fb_done`)。
### 35.2 验证(宿主 + qemu;0 新增真实 CDN 业务请求——web 接口仅宿主单次只读验证端点,见 35.4)
| 项 | 方式 | 结果 |
|---|---|---|
| 兜底链(新用例) | `cover_chain_sim`:T1-T10 存量 + **T11 原链死→song/detail 活链→出图** + **T12 原链死→兜底也死→歌单封面兜底 + 同曲冷却不重试** | **T1-T12 ALL PASS**(T11:守到 URL_LIVE 出图 + song/detail 恰 1 次 + 日志 `song-detail fallback`;T12:兜底也死留旧封面 + 再请求同曲 song/detail 0 次(冷却)+ 日志 `cooldown`) |
| 缓存 LRU/隔离 | `cache_evict_sim` | **PASS** |
| 播放状态机 | `player_flow_sim` P1-P12 | **PASS** |
| 大歌单滑窗 | `window_sim` | **PASS** |
| 搜索 | `search_sim` | **PASS** |
| 边下边播 | `stream_sim` | **ALL PASS** |
| core | `build_core.sh` → `test_ncm_core_host` + `qemu test_ncm_core_mipsel` | host + mipsel **PASS** |
| 构建 | `build_ncm.sh`(-Wall -Wextra) | **0 warning**;`text 1022210 / data 1648 / bss 1362564`(bss 仅 +64B,兜底曲目/时间戳 statics) |
| 宿主构建 | `build_ncm_host.sh` | 0 warning(仅 SDK `meow_ui_vol.c` 存量 `system()` 告警,非本批) |
| UI 渲染 | `NCM_PAGE=home/player/playlist MEOW_UI_DUMP` | exit=0,dump 正常 |
### 35.3 md5(本批产物)
```
e1f4b7ade834b18c7910b9c3e64bc858 ncm # 新(含兜底链;未部署,留主会话上卡)
d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进制(重链)
97bb2bdb44a2f446877787d9261b4f02 test/test_ncm_core_host # 宿主 core 测试(重编,内容未变)
```
改动文件(本批,仅这些):
```
修改:
app/logic/cover.c # cover_request 增 song_id + g_tgt_song/g_fb_song/g_fb_ms/g_fb_done + cover_song_detail_pic + cover_try_fallback + worker 死链分支先试兜底 + 头/注释
app/ui/player.c # 三处 cover_request 传曲目 s.id(触发专辑封面兜底)
app/ui/playlist.c # cover_request(pcur->cover, NULL)(歌单封面无兜底)
app/ncm_app_internal.h # cover_request 声明加 song_id
bench/cover_chain_sim.c # sim 补 ncm_json/jsmn/ncm_common 桩 + song/detail/dead/alt stub + T11/T12 + 双参 cover_request
ncm / ncm_host # 产物重链
```
(未动 sdk/、meow_launcherd/、其它 App、devdocs/;0 新增真实 CDN 业务请求;未部署——留主会话统一上卡。)
### 35.4 诚实出入 / 遗留
- **web 端点有效性**已宿主单次只读验证(`ids=%5B<id>%5D`,`code:200`,`songs[0].album.picUrl`/`blurPicUrl`
非空且为活图)——但**未在真机跑通整条兜底链**(本批未部署,留主会话上卡后观察 `cover: song-detail fallback`
日志)。真机预期:死链封面切歌时后台先撞原链(1 次 404/403/非图),立即 song/detail 重取(1 次,
免登录、带 Referer/UA),拿到活链二次拉图出封面;活链封面不再有兜底开销。
- **song/detail 响应缓冲 16KB**(栈):单曲 detail ~3.3KB 足够;个别超大响应 >16KB 会 `NCM_ERR_LIMIT`
→ 兜底取不到图回落歌单封面(可接受;未做动态增长,规避设备 RAM 压力)。
- **瞬时 song/detail 失败不重试**:song/detail 网络/TLS 瞬断 → 视为「无活图」回落歌单封面并记冷却
(同曲 5 分钟内不重撞)。保守但避免高频撞接口——重试救不了死链接,瞬断概率低。
- **冷却按曲目 id**(`g_fb_song`):切回同一曲 5 分钟内不重复兜底;换曲立即解锁。兜底后 `g_fb_done=1`
防止同 incident 二次兜底(新图也死时不再回 song/detail)。
- **歌单封面兜底语义未变**:兜底链两级都失败后仍清目标、保留当前显示封面(歌单封面在进歌单时已由
`cover_request(pcur->cover, NULL)` 取到)。未新增对 `g_st.cover_url` 的主动重取。
+100 -4
View File
@@ -45,6 +45,10 @@ static pthread_mutex_t g_cm = PTHREAD_MUTEX_INITIALIZER;
static meow_img *g_cover_img; static meow_img *g_cover_img;
static char g_cover_url[512]; /* g_cover_img 对应的 URL */ static char g_cover_url[512]; /* g_cover_img 对应的 URL */
static char g_tgt_url[512]; /* 目标 URL(worker 拉取) */ 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 pthread_t g_thr;
static int g_thr_on; static int g_thr_on;
static int g_quit; static int g_quit;
@@ -52,6 +56,15 @@ static volatile int g_gen; /* 每次换入 +1 */
static int g_acked; /* UI 已消费的版本 */ static int g_acked; /* UI 已消费的版本 */
static char g_cover_path[512]; /* 封面原始文件路径(供 launcher) */ 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 名即可命中)。 /* 封面缓存键:URL 的 djb2 哈希(稳定性由 URL 决定——同一专辑封面 URL 名即可命中)。
* 走 ncm_cache(LRU 口径沿用 ncm_cache,见 REPORT §14.2):键进 ncm_cache_path => 文件 * 走 ncm_cache(LRU 口径沿用 ncm_cache,见 REPORT §14.2):键进 ncm_cache_path => 文件
* `song_cov<hex>.jpg`,与 mp3(song_<id>.mp3)同池,lookup/commit 刷新 mtime(LRU)。 */ * `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; 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。 /* 下载 url 到 cache 写者 w(流式,写进其临时文件),成功后从临时文件解码并返回 img。
* 失败返回 NULL(不 commit,调用方 abort)。*out_len=body 字节数;*out_rc 与 *out_status * 失败返回 NULL(不 commit,调用方 abort)。*out_len=body 字节数;*out_rc 与 *out_status
* 回填下载 rc 与 HTTP status(封面 4xx 加固判定:确定性 4xx 不重试,REPORT §NEW 任务3); * 回填下载 rc 与 HTTP status(封面 4xx 加固判定:确定性 4xx 不重试,REPORT §NEW 任务3);
@@ -246,8 +329,11 @@ static void *cover_worker(void *arg)
usleep(250 * 1000); usleep(250 * 1000);
continue; continue;
} }
char song[24];
char url[512]; char url[512];
ncm_strlcpy(url, g_tgt_url, sizeof url); 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); pthread_mutex_unlock(&g_cm);
char key[32]; char key[32];
@@ -296,9 +382,10 @@ static void *cover_worker(void *arg)
* - 仅瞬时错误(超时/网络/TLS/5xx)指数退避重试。 * - 仅瞬时错误(超时/网络/TLS/5xx)指数退避重试。
* 日志一律打 rc/status(排查资产:先前只见 "fetch failed" 不知 HTTP 层返了什么); * 日志一律打 rc/status(排查资产:先前只见 "fetch failed" 不知 HTTP 层返了什么);
* 403 单独日志(防盗链特征,与 404 死链区分——三者修法不同)。 */ * 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)", meow_log(MEOW_LOG_WARN, "cover: webp unsupported url=%.64s rc=%d status=%d (no decode)",
url, drc, dstatus); url, drc, dstatus);
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm); pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张(走兜底) */ if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张(走兜底) */
pthread_mutex_unlock(&g_cm); pthread_mutex_unlock(&g_cm);
@@ -307,9 +394,10 @@ static void *cover_worker(void *arg)
} }
if (dkind == 2) { /* 200 但 body 非可解码图(源站死对象回 JSON 错误体/未知格式): if (dkind == 2) { /* 200 但 body 非可解码图(源站死对象回 JSON 错误体/未知格式):
* 真机矩阵(REPORT §34.1)确认为「200 解码失败」主因,老逻辑空转 8 次; * 真机矩阵(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)", meow_log(MEOW_LOG_WARN, "cover: not an image (origin error body/200) url=%.64s rc=%d status=%d (no retry)",
url, drc, dstatus); url, drc, dstatus);
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm); pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0;
pthread_mutex_unlock(&g_cm); pthread_mutex_unlock(&g_cm);
@@ -328,6 +416,7 @@ static void *cover_worker(void *arg)
url, drc, dstatus); url, drc, dstatus);
} }
if (deterministic) { if (deterministic) {
if (song[0] && !fb_done && cover_try_fallback(url, song)) { backoff_ms = 0; fails = 0; continue; }
pthread_mutex_lock(&g_cm); pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张 */ if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; /* 立即跳下一张 */
pthread_mutex_unlock(&g_cm); pthread_mutex_unlock(&g_cm);
@@ -370,10 +459,11 @@ static void *cover_worker(void *arg)
return NULL; 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),使 * 先 cover_norm_url 规范化(追加 ?param=300y300 缩略后缀,REPORT §NEW 任务2),使
* g_tgt_url/g_cover_url/缓存键/下载 URL 走同一参数版本(小图省内存、命中高)。 */ * 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 (!url || !url[0]) return;
if (g_host_mock) return; /* 宿主 mock = 0 网络纪律(mock URL 不得发真实 HTTPS) */ 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) || 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; } (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_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); pthread_mutex_unlock(&g_cm);
if (!g_thr_on) { if (!g_thr_on) {
g_thr_on = 1; 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; } if (g_cover_img) { meow_img_free(g_cover_img); g_cover_img = NULL; }
g_cover_url[0] = 0; g_cover_url[0] = 0;
g_tgt_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; g_cover_path[0] = 0;
pthread_mutex_unlock(&g_cm); pthread_mutex_unlock(&g_cm);
g_gen++; g_gen++;
+1 -1
View File
@@ -451,7 +451,7 @@ int lyric_state(void); /* 0=空闲/未拉 1=加载中 2=已拉完(可能
long lyric_line_time(int i);/* 第 i 行时间戳(ms;越界 -1)。播放页点行跳转(§22) */ long lyric_line_time(int i);/* 第 i 行时间戳(ms;越界 -1)。播放页点行跳转(§22) */
/* cover */ /* cover */
void cover_request(const char *url); /* 提交目标封面 URL(后台线程下载+解码) */ void cover_request(const char *url, const char *song_id); /* 提交目标封面 URL(后台线程下载+解码);song_id 曲目封面死链时 song/detail 兜底重取,NULL=歌单封面无兜底 */
meow_img *cover_get(void); /* 当前封面(**持锁返回**;NULL=无;用后必须 cover_put) */ meow_img *cover_get(void); /* 当前封面(**持锁返回**;NULL=无;用后必须 cover_put) */
void cover_put(void); /* 配对 cover_get 释放锁(§18 修绘制期 UAF) */ void cover_put(void); /* 配对 cover_get 释放锁(§18 修绘制期 UAF) */
int cover_changed(void); /* 1=有新封面待绘制(UI tick 判变) */ int cover_changed(void); /* 1=有新封面待绘制(UI tick 判变) */
+3 -3
View File
@@ -538,7 +538,7 @@ void player_play_song(int idx)
? s.al_pic : g_st.cover_url; ? s.al_pic : g_st.cover_url;
player_manager_play(idx); player_manager_play(idx);
appstate_set_playing_song(idx, NULL); appstate_set_playing_song(idx, NULL);
cover_request(cover); cover_request(cover, s.id); /* 曲目专辑封面:带 id,死链时 song/detail 兜底(REPORT §NEW 任务1) */
g_ppage = 0; /* 新曲 → 回封面页 */ g_ppage = 0; /* 新曲 → 回封面页 */
meow_view_page_push(ACT_PLAYER); meow_view_page_push(ACT_PLAYER);
} }
@@ -554,7 +554,7 @@ void player_play_search_song(int i)
const char *cover = s.al_pic[0] ? s.al_pic : g_st.cover_url; const char *cover = s.al_pic[0] ? s.al_pic : g_st.cover_url;
player_manager_play(i); player_manager_play(i);
appstate_set_playing_song(i, NULL); appstate_set_playing_song(i, NULL);
cover_request(cover); cover_request(cover, s.id);
g_ppage = 0; g_ppage = 0;
meow_view_page_push(ACT_PLAYER); meow_view_page_push(ACT_PLAYER);
} }
@@ -585,7 +585,7 @@ void player_tick(uint64_t now)
ncm_song_t s2; ncm_song_t s2;
if (sc >= 0 && cur_song_meta(&s2) == 0 && strcmp(s2.id, g_last_meta_id) != 0) { if (sc >= 0 && cur_song_meta(&s2) == 0 && strcmp(s2.id, g_last_meta_id) != 0) {
ncm_strlcpy(g_last_meta_id, s2.id, sizeof g_last_meta_id); ncm_strlcpy(g_last_meta_id, s2.id, sizeof g_last_meta_id);
cover_request(s2.al_pic[0] ? s2.al_pic : g_st.cover_url); cover_request(s2.al_pic[0] ? s2.al_pic : g_st.cover_url, s2.id);
player_static_invalidate(); player_static_invalidate();
player_dyn_invalidate(); player_dyn_invalidate();
} }
+1 -1
View File
@@ -548,7 +548,7 @@ static void pl_page_enter(meow_view *v, void *ctx)
* (旧版只在 playlist_open 里 cover_cache_reset 清掉,无人再请求 → 头块永远占位 ♪)。 */ * (旧版只在 playlist_open 里 cover_cache_reset 清掉,无人再请求 → 头块永远占位 ♪)。 */
const ncm_playlist_t *pcur = pl_cur(); const ncm_playlist_t *pcur = pl_cur();
if (pcur && pcur->cover[0]) if (pcur && pcur->cover[0])
cover_request(pcur->cover); cover_request(pcur->cover, NULL); /* 歌单封面:无曲目 id,不走 song/detail 兜底 */
playlist_refresh(); playlist_refresh();
} }
+75 -11
View File
@@ -49,6 +49,16 @@ size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
while (src[n]) n++; while (src[n]) n++;
return 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 int g_log_warns;
static char g_last_log[512]; /* 最近一条日志文本(webp/403 分诊日志断言用) */ 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 可对照旧代码(新代码强符号覆盖)。 */ /* §18 前的 cover.c 无 cover_put:weak 空实现让同一 sim 可对照旧代码(新代码强符号覆盖)。 */
__attribute__((weak)) void cover_put(void) { } __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_http_calls;
static int g_sd_calls; /* song/detail 请求计数(死链兜底专用,REPORT §NEW 任务1) */
static int g_fail_next; /* 剩余注入失败次数 */ static int g_fail_next; /* 剩余注入失败次数 */
static int g_delay_ms; /* 模拟慢网络 */ static int g_delay_ms; /* 模拟慢网络 */
static int g_webp_next; /* 下一次下载返回 WebP 魔数体(200,测「200 解码失败」三分型③) */ static int g_webp_next; /* 下一次下载返回 WebP 魔数体(200,测「200 解码失败」三分型③) */
static int g_json_next; /* 下一次下载返回 JSON 错误体(200,实测「200 解码失败」真凶=死对象回 JSON) */ static int g_json_next; /* 下一次下载返回 JSON 错误体(200,实测「200 解码失败」真凶=死对象回 JSON) */
static int g_status_next; /* 下一次下载强制返回该 HTTP status(测 403 防盗链;0=200) */ 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) 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++; g_http_calls++;
if (g_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; } if (g_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; }
if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000); 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; size_t n = 4096 < buf_sz ? 4096 : buf_sz;
unsigned long h = djb2(req->url); unsigned long h = djb2(req->url);
for (size_t i = 0; i < n; i++) buf[i] = (char)((h >> ((i % 8) * 8)) ^ (unsigned char)i); 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; (void)max_bytes;
g_http_calls++; 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_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; }
if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000); if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000);
unsigned char buf[4096]; 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_E "http://p1.music.126.net/albumE/vvv.jpg" /* webp 注入用(未缓存) */
#define URL_F "http://p1.music.126.net/albumF/uuu.jpg" /* 403 注入用(未缓存) */ #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_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) int main(void)
{ {
@@ -262,7 +301,7 @@ int main(void)
ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir); ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir);
printf("== T1: first request -> cover appears ==\n"); 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(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(g_http_calls == 1, "http calls=%d (expect 1)", g_http_calls);
CHECK(curjpg_hash() == url_hash(URL_A), "cover_current.jpg == A"); 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); CHECK(g_http_calls == 1, "http calls=%d (still 1)", g_http_calls);
printf("== T3: switch song -> cover switches + cover_current.jpg updated ==\n"); 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(wait_cover_hash(url_hash(URL_B), 3000), "cover switched to B");
CHECK(curjpg_hash() == url_hash(URL_B), "cover_current.jpg == 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); 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"); CHECK(!cover_changed(), "no churn after switch");
printf("== T4: re-request same url -> dedup ==\n"); printf("== T4: re-request same url -> dedup ==\n");
cover_request(URL_B); cover_request(URL_B, NULL);
usleep(500 * 1000); usleep(500 * 1000);
CHECK(g_http_calls == 2, "http calls=%d (still 2, dedup)", g_http_calls); CHECK(g_http_calls == 2, "http calls=%d (still 2, dedup)", g_http_calls);
printf("== T5: flaky network x2 -> backoff retry succeeds ==\n"); printf("== T5: flaky network x2 -> backoff retry succeeds ==\n");
g_fail_next = 2; 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(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_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); 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); printf("== T6: permanent failure -> give up after %d tries ==\n", 8);
g_fail_next = 100; g_fail_next = 100;
int before = g_http_calls; 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),分段等 */ 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); for (int t = 0; t < 30 && g_http_calls - before < 8; t++) usleep(1000 * 1000);
int tries = g_http_calls - before; int tries = g_http_calls - before;
@@ -312,7 +351,7 @@ int main(void)
g_fail_next = 0; g_fail_next = 0;
int b8 = g_http_calls; int b8 = g_http_calls;
g_webp_next = 1; g_webp_next = 1;
cover_request(URL_E); cover_request(URL_E, NULL);
usleep(800 * 1000); /* 等 worker 处理 webp 体 → 判 webp → 清目标 → 转怠速 */ usleep(800 * 1000); /* 等 worker 处理 webp 体 → 判 webp → 清目标 → 转怠速 */
CHECK(g_http_calls == b8 + 1, "webp: single download=%d (no retry)", g_http_calls - b8); 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 保留(兜底,不误换)"); 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"); printf("== T9: HTTP 403 anti-hotlink -> single download, no retry, distinct log ==\n");
int b9 = g_http_calls; int b9 = g_http_calls;
g_status_next = 403; g_status_next = 403;
cover_request(URL_F); cover_request(URL_F, NULL);
usleep(800 * 1000); usleep(800 * 1000);
CHECK(g_http_calls == b9 + 1, "403: single download=%d (no retry)", g_http_calls - b9); 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 保留(兜底)"); 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"); printf("== T10: 200+JSON error body (dead origin) -> not an image, no retry ==\n");
int b10 = g_http_calls; int b10 = g_http_calls;
g_json_next = 1; g_json_next = 1;
cover_request(URL_G); cover_request(URL_G, NULL);
usleep(800 * 1000); usleep(800 * 1000);
CHECK(g_http_calls == b10 + 1, "json: single download=%d (no retry)", g_http_calls - b10); 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(cur_cover_hash() == url_hash(URL_C), "json: 旧封面 C 保留(兜底)");
CHECK(strstr(g_last_log, "not an image") != NULL, "log: 'not an image' seen"); 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"); printf("== T7: null/empty + reset ==\n");
int b7 = g_http_calls; int b7 = g_http_calls;
cover_request(NULL); cover_request(NULL, NULL);
cover_request(""); cover_request("", NULL);
CHECK(g_http_calls == b7, "null/empty ignored (calls=%d)", g_http_calls); CHECK(g_http_calls == b7, "null/empty ignored (calls=%d)", g_http_calls);
cover_cache_reset(); cover_cache_reset();
CHECK(cur_cover_hash() == 0, "reset -> cover cleared"); CHECK(cur_cover_hash() == 0, "reset -> cover cleared");
Binary file not shown.
Binary file not shown.