Files
MeowOS-For-R3P2/patch_v2/ncm_app/app/logic/cover.c
T
AskaEth de18cef02c feat(ncm): 缓存分目录(songs//covers/)+ 陈旧 mp3 缓存修复
任务1 缓存分目录(用户定案):\n- ncm_cache_init mkdir songs/ 与 covers/ 子目录 + 一次性迁移\n  (根混放 song_*/cov*/cover_current.jpg 挪进两子目录,幂等,legacy_migrated 计数)\n- ncm_cache_path 按 id 前缀路由(cov*→covers/,其余→songs/)\n- LRU(evict/total_bytes/7天 .part 回收)只扫 songs/;clear 清 songs/+covers/ 的 song_*\n- cover.c save_cover_raw 落 covers/cover_current.jpg\n\n任务2 陈旧 mp3 缓存修复(真机实锤:hires 档键下存 mp3,播放命中旧档):\n- song_local_file 缓存命中且用户档>EXHIGH(无损+)且实体是 .mp3 = 静默降级产物\n  → 迁移重命名到 exhigh 键 song_<id>_exhigh.mp3(选极高仍可用;目标已存在删旧)\n  → 本档视为未命中走下载(311 flac 门禁拿真无损/正确降级)\n\n验证:cache_evict_sim case10(目录迁移一次成型)+P1-9;player_flow_sim P13(迁移+miss)\n+P1-12 零回归;stream/window/search/degrade/cover_chain/test_ncm_core(host+qemu) 全 PASS;\nbuild_ncm.sh -Wall -Wextra 0 warning。0 真实请求。新 ncm md5 a90ba8c3 (text1028458/bss1364484)。
2026-09-02 13:51:36 +08:00

554 lines
28 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* logic/cover.c — 封面下载(HTTPS)+ 限幅 + 解码缓存(单张,URL 变更即换;后台线程)。
*
* 内存纪律:下载限幅 NCM_COVER_MAX(字节上限);解码后的 RGBA 保持单张(换封面先 free 旧再解码)。
* 线程模型:一个持久的 cover 工作线程负责「目标 URL → 下载 → 解码 → 换入」;UI 线程只经
* cover_request(url)(提交目标)与 cover_get()/cover_changed()(读取/判变)+ cover_ack()(消费)。
* 工作线程不碰 meow_ui(绘制在 UI 线程 tick 判 cover_changed 后触发 invalidate)。
*
* §18 修复(切歌封面不更新/CPU 忙等根因):
* - worker 换入成功后**清目标**(g_tgt_url[0]=0)转怠速——旧版成功后不清目标,每圈
* 缓存读+JPEG 解码+换入+g_gen++ 无 sleep 死循环:CPU 占满、cover_changed() 恒真导致
* 播放页每帧整盘重绘,慢网络下新封面请求被饿到表现如「切歌封面不更新」。
* - 下载/解码失败**指数退避**重试(0.5s→4s),连续 8 次放弃(UI 重进页会重新提交目标);
* 失败写 WARN 日志(排查资产),不再无声死等。
* - 绘制安全:cover_get() 持锁返回、cover_put() 解锁——旧版 UI 绘制期 worker 可 free
* 同一 meow_img(use-after-free);调用方须在 cover_get 后配对 cover_put。
*
* §30 修复(真机封面全加载失败):旧版 `static g_buf[NCM_COVER_MAX(288KB)]` 作为下载缓冲,
* 网易云封面全尺寸实测 ~1.34MB(>288KB)→ ncm_http 回 NCM_ERR_FULL → 下载失败×8 放弃。
* 且设备 RAM 仅 56MB,不能再涨静态 bss。改为**流式写文件**:ncm_http_download 的写回调直接
* 把 body 写进 ncm_cache 写者临时文件(write(2) 直写,无 stdio 缓冲),meow_img_load_file 从
* 临时文件解码,commit 落盘。全程零封面大缓冲、无 bss 上涨;NCM_COVER_MAX 仅作下载字节上限。
*/
#include "../ncm_app_internal.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include <unistd.h>
#include <sys/stat.h>
/* 封面 CDN 请求头(REPORT §NEW 任务2,对齐 CNMPlayer):网易云图片 CDN 靠 Referer 防盗链,
* 缺失被拦返回 403(p3.music.126.net 150B text/html,REPORT §NEW 三分型②);UA 分表口径与
* CNMPlayer 客户端一致(Mozilla/5.0 CNMPlayer/0.1 + Referer https://music.163.com/)。两者
* 均为常量:真机矩阵实测需换 UA 时只改 NCM_COVER_UA 一处,不涉逻辑(见 REPORT §NEW 矩阵结论)。 */
#define NCM_COVER_REFERER "https://music.163.com/"
/* UA 分表(真机矩阵实测结论):本批在设备端逐 UA 试,`Mozilla/5.0 CNMPlayer/0.1`(CNMPlayer 口径)
* 对封面 CDN 同 Referer 也返 403(CDN 拦 bot 串);桌面浏览器 UA(旧 cover.c 一直用、设备实测返 200)
* 被 CDN 接受。故封面走**桌面浏览器 UA**,Referer 对齐 CNMPlayer(防盗链,防裸请求 403)。 */
#define NCM_COVER_UA "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " \
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
/* ?param=WxH 缩略后缀宏 NCM_COVER_PARAM / NCM_COVER_USE_PARAM 见 ncm_app_internal.h(与
* bench/cover_chain_sim.c 同源)。 */
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;
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)。 */
static void cover_cache_id(const char *url, char *out, size_t out_sz)
{
unsigned long h = 5381;
for (const char *p = url; *p; p++) h = ((h << 5) + h) + (unsigned char)*p;
snprintf(out, out_sz, "cov%lx", h);
}
/* 文件存在且非空(封面缓存命中用;streaming 后无需整读进内存)。 */
static int file_nonempty(const char *path)
{
struct stat st;
return (path && stat(path, &st) == 0 && st.st_size > 0) ? 1 : 0;
}
/* 流式写回调:body 字节直接写进 ncm_cache 写者临时文件(write(2) 直写)。ctx=cover_sink_t*。 */
typedef struct { ncm_cache_writer_t *w; size_t total; } cover_sink_t;
static int cover_sink_write(void *ctx, const unsigned char *data, size_t len)
{
cover_sink_t *s = (cover_sink_t *)ctx;
if (ncm_cache_write(s->w, data, len) != 0) return -1;
s->total += len;
return 0;
}
/* 封面 URL 规范化:追加 NCM_COVER_PARAM 尺寸后缀(REPORT §NEW 任务2)。已含 param= 的
* URL 原样保留(尊重调用方/宿主食之已有尺寸);否则追加(已有其它查询用 &,无则用 ?)。
* 溢出(512 缓冲放不下)则放弃追加——宁可用全尺寸 URL 也不截断出坏 URL。 */
static void cover_norm_url(const char *src, char *out, size_t out_sz)
{
ncm_strlcpy(out, src, out_sz);
#if NCM_COVER_USE_PARAM
if (out[0] && strstr(out, "param=")) return; /* 已指定尺寸,不叠 */
size_t l = strlen(out);
size_t add = strlen("?param=") + strlen(NCM_COVER_PARAM);
if (!out[0] || l + add + 1 >= out_sz) return; /* 放不下:保原 URL */
strcat(out, strchr(out, '?') ? "&param=" : "?param=");
strcat(out, NCM_COVER_PARAM);
#endif
}
/* 大小写不敏感的 posix 子串检测(内容 webp 判定用;不依赖 strcasestr/_GNU_SOURCE 桩)。 */
static int str_ci_contains(const char *hay, const char *needle)
{
if (!hay || !needle) return 0;
size_t n = strlen(needle);
if (!n) return 1;
for (; *hay; hay++) {
if (tolower((unsigned char)hay[0]) == tolower((unsigned char)needle[0])) {
const char *a = hay, *b = needle;
while (*b && tolower((unsigned char)*a) == tolower((unsigned char)*b)) { a++; b++; }
if (!*b) return 1;
}
}
return 0;
}
/* 文件是否 WebP:魔数 RIFF....WEBP(stb_image 不支持 webp,解码必 NULL)。读文件前 12 字节。 */
static int file_is_webp(const char *path)
{
if (!path) return 0;
unsigned char b[12];
FILE *f = fopen(path, "rb");
if (!f) return 0;
size_t n = fread(b, 1, sizeof b, f);
fclose(f);
if (n < 12) return 0;
return b[0]=='R'&&b[1]=='I'&&b[2]=='F'&&b[3]=='F' &&
b[8]=='W'&&b[9]=='E'&&b[10]=='B'&&b[11]=='P';
}
/* body 是否为 stb 可解码的静态图:JPEG(FFD8FF)/PNG(89504E47)/BMP(424D)/GIF(474946)。
* 真机矩阵(REPORT §34.1)实锤:源站死对象对封面 URL 返 **200 + JSON 错误体**(43B `{"code..`,
* content-type application/json),旧逻辑 stbi 解码 NULL → 当成「fetch failed」盲指数重试 8 次(真机
* 日志 `rc=0 status=200` + `retry`)。故 200 且 body 非**已知可解码图**(含 JSON 错误体/未知格式)
* 均判确定性失败不重试(重试救不了死对象)。content-type 明确非 image/ 前缀(如 application/json)
* 也直接判非图。 */
static int body_is_decodable_img(const char *path, const ncm_http_resp_t *resp)
{
if (resp && resp->has_content_type && resp->content_type[0]) {
if (strncmp(resp->content_type, "image/", 6) != 0) return 0; /* 如 application/json */
}
if (!path) return 0;
unsigned char b[8];
FILE *f = fopen(path, "rb");
if (!f) return 0;
size_t n = fread(b, 1, sizeof b, f);
fclose(f);
if (n < 4) return 0;
if (b[0]==0xFF && b[1]==0xD8 && b[2]==0xFF) return 1; /* JPEG */
if (b[0]==0x89 && b[1]=='P' && b[2]=='N' && b[3]=='G') return 1; /* PNG */
if (b[0]=='B' && b[1]=='M') return 1; /* BMP */
if (b[0]=='G' && b[1]=='I' && b[2]=='F' && b[3]=='8') return 1; /* GIF */
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);
* *out_kind 回填 200 体类型:0=可解码图、1=WebP(stb 不支持)、2=非图(源站死对象 200+JSON 错误体/
* 未知格式)——1/2 均不解码,确定性不重试(REPORT §34.1 真机矩阵:200 解码失败实为死对象回 JSON 体)。 */
static meow_img *download_cover(const char *url, ncm_cache_writer_t *w, size_t *out_len,
int *out_rc, int *out_status, int *out_kind)
{
ncm_http_req_t req;
memset(&req, 0, sizeof req);
/* 头:Referer 防盗链(CNMPlayer 口径)+ 桌面浏览器 UA(真机矩阵:CNMPlayer UA 被拦 403)。 */
char hdrs[256];
snprintf(hdrs, sizeof hdrs, "Referer: %s\r\nUser-Agent: %s", NCM_COVER_REFERER, NCM_COVER_UA);
req.url = url;
req.method = "GET";
req.extra_hdrs = hdrs;
req.verify_ca = NCM_HTTP_VERIFY_DEFAULT;
req.timeout_ms = 15000;
ncm_http_resp_t resp;
cover_sink_t sink = { w, 0 };
int rc = ncm_http_download(&req, cover_sink_write, &sink, NCM_COVER_MAX, &resp);
if (out_rc) *out_rc = rc;
if (out_status) *out_status = resp.status;
if (rc != NCM_OK || resp.status != 200 || sink.total == 0) return NULL;
if (out_len) *out_len = sink.total;
/* 从写者临时文件解码(未 commit,文件仍在 .tmp;解码到内存 RGBA,与落盘无关) */
const char *tmp = ncm_cache_writer_tmp_path(w);
if (!tmp) return NULL;
/* WebP 先判:content-type 含 webp 或文件魔数 RIFF....WEBP → 不解码(stb 不支持),
* 回 webp=1 走兜底;避免 stbi_load 空转返回 NULL 后当成「fetch failed」盲指数退重试。 */
if (out_kind) *out_kind = 0;
if ((resp.has_content_type && str_ci_contains(resp.content_type, "webp")) || file_is_webp(tmp)) {
if (out_kind) *out_kind = 1; /* webp(stb 不支持) */
return NULL;
}
if (!body_is_decodable_img(tmp, &resp)) {
if (out_kind) *out_kind = 2; /* 非图(源站死对象 200+JSON 错误体/未知格式) */
return NULL;
}
return meow_img_load_file(tmp);
}
/* 封面原始文件路径写入(供 launcher 媒体卡片读;兼容保留)。调用方须已持有 g_cm。
* 从 cache 写者临时文件把已下载字节复制到 cover_current.jpg(早前版本在此内再 lock g_cm
* → 非递归锁自锁死——cover worker 永久卡死、封面永不出现)。
* §NEW 缓存分目录:与歌曲分池,cover_current.jpg 落 covers/(封面条目 song_cov* 也在 covers/)。 */
static void save_cover_raw(const char *path, size_t len)
{
if (!path || len == 0) return;
const char *covdir = g_st.ctx.cache.covers_dir[0] ? g_st.ctx.cache.covers_dir
: g_st.cache_dir;
if (covdir[0]) {
char dst[NCM_CACHE_DIR_MAX + 32];
snprintf(dst, sizeof dst, "%s/cover_current.jpg", covdir);
/* 文件复制(源=临时文件,目标=cover_current.jpg)。 */
FILE *in = fopen(path, "rb");
FILE *out = NULL;
if (in) {
out = fopen(dst, "wb");
if (out) {
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof buf, in)) > 0)
if (fwrite(buf, 1, n, out) != n) { fclose(out); out = NULL; break; }
if (out) fclose(out);
}
fclose(in);
}
ncm_strlcpy(g_cover_path, dst, sizeof g_cover_path);
}
}
#define COVER_RETRY_MAX 8 /* 连续失败放弃次数(UI 重进页/切歌会重新提交目标) */
/* 内存瘦身:封面解码后按显示上限降采样驻留。网易云全尺寸封面(如 1448×1448)解码成
* RGBA = 8MB,而应用显示只需 ≤480px(播放页黑胶方图+歌词页模糊铺底均 ≤ 屏幕 480 宽)——
* 缩到 480×480 = 0.9MB,省 ~7MB anon(设备 RAM 仅 56MB,OOM 真机被杀,最高优先级)。
* 若已 ≤上限(小缩略图)则原样保留。返回本函数处理后的图(可能替换/释放旧图)。 */
#define NCM_COVER_DECODE_MAX 480
static meow_img *cover_cap(meow_img *im)
{
if (!im) return NULL;
meow_img *small = meow_img_downscale(im, NCM_COVER_DECODE_MAX);
if (small) { meow_img_free(im); return small; } /* 已缩放:释放原始大图,换小图驻留 */
return im; /* 本就够小:保留原图 */
}
static void *cover_worker(void *arg)
{
(void)arg;
int backoff_ms = 0, fails = 0;
while (!g_quit) {
pthread_mutex_lock(&g_cm);
if (!g_tgt_url[0]) {
pthread_mutex_unlock(&g_cm);
backoff_ms = 0; fails = 0;
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];
cover_cache_id(url, key, sizeof key);
size_t blen = 0;
meow_img *img = NULL;
int drc = NCM_ERR_NET, dstatus = 0, dkind = 0; /* 下载 rc/HTTP status/200 体类型(分诊用) */
/* ① 缓存命中直接解码(免下载、离线可看;LRU:lookup 刷新 mtime) */
char cpath[512];
if (g_st.ctx.cache.dir[0] && ncm_cache_lookup(&g_st.ctx.cache, key, ".jpg",
cpath, sizeof cpath) == NCM_OK &&
file_nonempty(cpath)) {
img = meow_img_load_file(cpath);
}
/* ② 未命中 → 流式下载解码;成功后落进 ncm_cache(LRU 池,超限由 ncm_cache_evict 淘汰) */
if (!img) {
if (g_st.ctx.cache.dir[0]) {
ncm_cache_evict(&g_st.ctx.cache);
ncm_cache_writer_t *w = NULL;
if (ncm_cache_open(&g_st.ctx.cache, key, ".jpg", &w) == NCM_OK) {
blen = 0;
img = download_cover(url, w, &blen, &drc, &dstatus, &dkind);
if (img) {
pthread_mutex_lock(&g_cm);
save_cover_raw(ncm_cache_writer_tmp_path(w), blen);
pthread_mutex_unlock(&g_cm);
if (ncm_cache_commit(w) != 0) { ncm_cache_abort(w); }
} else {
ncm_cache_abort(w);
}
ncm_cache_evict(&g_st.ctx.cache);
} else {
/* 打开写者失败(罕见):退避重试,不空转 */
img = NULL;
}
}
}
/* 内存瘦身:全尺寸封面解码后降采样到 ≤480px 驻留(省 ~7MB anon,OOM 修复)。 */
if (img) img = cover_cap(img);
if (!img) {
/* 失败分诊(REPORT §NEW 任务3):
* - webp:200 但 stb 不支持解码(三分型③),**非瞬时、非重试可愈** → 立即清目标
* 走兜底(播放页保留歌单封面 g_st.cover_url,不盲目指数重试空转);
* - 确定性 4xx(404 死链 / **403 防盗链**)**不重试**,立即清目标跳下一张;
* - 仅瞬时错误(超时/网络/TLS/5xx)指数退避重试。
* 日志一律打 rc/status(排查资产:先前只见 "fetch failed" 不知 HTTP 层返了什么);
* 403 单独日志(防盗链特征,与 404 死链区分——三者修法不同)。 */
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);
backoff_ms = 0; fails = 0;
continue;
}
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);
backoff_ms = 0; fails = 0;
continue;
}
int deterministic = (dstatus >= 400 && dstatus < 500 && dstatus != 408 && dstatus != 429);
if (dstatus == 403) {
meow_log(MEOW_LOG_WARN, "cover: fetch fail url=%.64s rc=%d status=403 (anti-hotlink/forbidden, no retry)",
url, drc);
} else if (deterministic) {
meow_log(MEOW_LOG_WARN, "cover: fetch fail url=%.64s rc=%d status=%d (4xx, no retry)",
url, drc, dstatus);
} else {
meow_log(MEOW_LOG_WARN, "cover: fetch fail url=%.64s rc=%d status=%d",
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);
backoff_ms = 0; fails = 0;
continue;
}
/* 瞬时错误:指数退避重试,连续 COVER_RETRY_MAX 次放弃(不无声死等;
* 目标保留期间 UI 每帧重请求由 cover_request 去重挡住)。 */
fails++;
if (fails >= COVER_RETRY_MAX) {
meow_log(MEOW_LOG_WARN, "cover: give up after %d fails url=%.64s", fails, url);
pthread_mutex_lock(&g_cm);
if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0;
pthread_mutex_unlock(&g_cm);
backoff_ms = 0; fails = 0;
continue;
}
meow_log(MEOW_LOG_WARN, "cover: fetch failed (%d) url=%.64s retry in %dms",
fails, url, backoff_ms ? backoff_ms : 500);
if (g_app_quit) return NULL;
usleep(backoff_ms ? backoff_ms * 1000 : 500 * 1000);
backoff_ms = backoff_ms ? (backoff_ms * 2 > 4000 ? 4000 : backoff_ms * 2) : 500;
continue;
}
backoff_ms = 0; fails = 0;
pthread_mutex_lock(&g_cm);
/* 目标仍是 url 才换入(期间未提交更早) */
if (g_tgt_url[0] && strcmp(g_tgt_url, url) == 0) {
if (g_cover_img) meow_img_free(g_cover_img);
g_cover_img = img;
ncm_strlcpy(g_cover_url, url, sizeof g_cover_url);
g_gen++;
g_tgt_url[0] = 0; /* 目标达成即清:worker 转怠速(修忙等死循环,§18) */
} else {
meow_img_free(img);
}
pthread_mutex_unlock(&g_cm);
}
return NULL;
}
/* 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, const char *song_id)
{
if (!url || !url[0]) return;
if (g_host_mock) return; /* 宿主 mock = 0 网络纪律(mock URL 不得发真实 HTTPS) */
char norm[512];
cover_norm_url(url, norm, sizeof norm);
const char *u = norm[0] ? norm : url; /* 规范化溢出保护:退回原 URL */
pthread_mutex_lock(&g_cm);
/* 去重:已在展示 或 已在拉取途中的同一 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;
if (ncm_thread_create(&g_thr, cover_worker, NULL) == 0)
pthread_detach(g_thr);
else
g_thr_on = 0;
}
}
/* UI 线程:读取当前封面——**持锁返回**,绘制完必须配对 cover_put()(§18 修 UAF:
* 旧版 get 后无锁绘制,worker 换入时 free 同一张图 → use-after-free)。 */
meow_img *cover_get(void)
{
pthread_mutex_lock(&g_cm);
return g_cover_img;
}
void cover_put(void)
{
pthread_mutex_unlock(&g_cm);
}
/* 是否有新封面待绘制(UI tick 判变)。 */
int cover_changed(void) { return g_gen != g_acked; }
/* UI 消费一次:标记已见当前版本。 */
void cover_ack(void) { g_acked = g_gen; }
/* 封面原始文件路径(供 launcher 媒体卡片读);NULL=无。 */
const char *cover_current_path(void)
{
const char *p;
pthread_mutex_lock(&g_cm);
p = g_cover_path[0] ? g_cover_path : NULL;
pthread_mutex_unlock(&g_cm);
return p;
}
void cover_cache_reset(void)
{
pthread_mutex_lock(&g_cm);
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++;
g_acked = g_gen;
}
#ifdef MEOW_UI_HOST_TEST
/* 宿主测试桩(仅宿主构建;产品构建无此符号):注入一张本地封面图(不经网络),
* 验证封面页方形圆角大图与歌词页模糊置底路径(§22)。0 真实请求纪律不变。 */
void cover_host_inject(const char *path)
{
if (!path || !path[0]) return;
meow_img *img = meow_img_load_file(path);
if (!img) { meow_log(MEOW_LOG_WARN, "cover host inject: load failed %s", path); return; }
pthread_mutex_lock(&g_cm);
if (g_cover_img) meow_img_free(g_cover_img);
g_cover_img = img;
ncm_strlcpy(g_cover_url, path, sizeof g_cover_url);
g_cover_path[0] = 0;
g_gen++;
pthread_mutex_unlock(&g_cm);
}
#endif