Merge branch 'feat/ncm-cache-filename-actual-level'

This commit is contained in:
AskaEth committed 2026-11-04 21:11:04 +08:00
commit 2c69070cc4
8 files changed
+999 -146

No files matched your search

+60
View File
@@ -3369,3 +3369,63 @@ MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires
dispose 的 join 可能多等 ~0.3–1s。磁盘瞬时要 2× 单曲空间(失败自动降级裸流)。
- **缓存队列点播的时长/封面**取自旁路 `meta/<id>.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。
- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。
## 39. M6:缓存文件名改为「实际档」(2026-11-04)
用户原话:「有降级显示,但是那个缓存文件名误导人」。背景:现缓存命名 `song_<id>_<请求档>.<实际格式>`,
当服务端对无损档只给 mp3 时降级链落到 exhigh,文件名却仍写 hires(真机 `song_171650_hires.mp3`),名不副实。
### 39.1 设计(先写方案再动代码)
- **命名**:新写入一律 `song_<id>_<实际档>.<实际格式>`,实际档由 `ncm_cache_actual_level(req, 音频参数,
响应生效档)` 判定(**单一事实源**:写盘名 / 播放页显示 / 已缓存列表三处共用)。
规则:`min(请求档, 容器可承载档, 降级链生效档)`,只降不升——
mp3 码率 >128k→exhigh、≤128k→standard(mp3 永不高于极高,码率未知时也钳到极高);
flac 16bit/44.1k→lossless、24/48→hires、24/96→jyeffect、24/192→jymaster。
典型:hires 请求 + 320k mp3 响应 → `song_171650_exhigh.mp3`。
- **命中语义(同歌同档不重复下载)**:`ncm_cache_lookup` 在请求档精确 miss 后**只降不升**逐档下探
(`surround→sky→jymaster→jyeffect→hires→lossless→exhigh→higher→standard`,DOLBY 不在链内)。
于是 hires 请求命中已存的 `song_<id>_exhigh.mp3` / `song_<id>_lossless.flac`,不重下;低档请求
不会向上命中高档实体(不偷跑流量/存储)。`.part` 续传键也用实际档,同名才续传/commit。
- **旧文件兼容(不强制迁移)**:旧命名 `song_<id>_hires.mp3`(及无档位段 `song_<id>.mp3`、历史双点名)
原样保留:按原键可命中、可播放、可单曲删除、可 LRU 淘汰;列表按文件头探测显示实际档(M5 既有链路)。
播放/列表的生效档由 `ncm_cache_level_from_path`(文件名档位)+ 容器钳顶得出:mp3 实体不可能高于
极高,故旧 `hires.mp3` 标注「极高」,与实际一致。**不再**像 §NEW 任务2 那样把旧 mp3 改名 + 强制重下。
- **去重展示**:同曲同实际档、两种命名并存时(旧请求档名 + 新实际档名),`ncm_cache_scan` 只保留
最近访问的一条;不同实际档(如 lossless.flac + exhigh.mp3)照常各列一行。
### 39.2 改动
| 文件 | 改动 |
|---|---|
| `core/ncm_cache.c/.h` | 新增 `ncm_cache_actual_level`(容器/码率/位深/响应档→实际档,只降不升)、`ncm_cache_level_from_path`(文件名档位解析,含 `sky_c51`);`ncm_cache_lookup` 拆出精确查找 + 档位下探链;`ncm_cache_scan` 复用实际档判定并按 (id, actual_level, ext) 去重 |
| `app/play/player_manager.c` | 写盘键改用响应实际档(`ext` 判定后计算,`.part`/`open`/日志同键);`eff_level` = 实际档;命中路径按文件名档位 + mp3 钳顶标注实际档;删除旧「stale-degraded 改名+强制重下」与「无档位键迁移」块(老卡文件免迁移) |
| `test/test_ncm_cache.c` + `build_ncm_cache_host.sh` | 新增纯 libc 宿主单测:命名生成/解析、降级后写入名、hires 命中已存 exhigh/lossless、低档不命中高档、旧命名命中/扫描/删除、双点名迁移、列表去重、LRU/清空/删除对两种命名兼容、`level_from_path` 边界 |
| `test/test_ncm_core.c` | 新增 `test_cache_actual_naming`(同一组语义接入 core 全量测试集;本机缺 vendor 未整链,仅 `-fsyntax-only` 0 warning) |
| `bench/player_flow_sim.c` | 适配:补 `ncm_cache_actual_level`/`ncm_cache_level_from_path` 桩与 M5 后缺失的链接桩(`appstate_cache_*`、`ncm_tag_embed_file`);P7/P12 期望键改实际档;P13 改为「旧命名命中即播 + 实际档显示,不重下不迁移」 |
### 39.3 验证
| 项 | 结果 |
|---|---|
| `./build_ncm_cache_host.sh`(真实 `core/ncm_cache.c` + `core/ncm_tag.c`,仅桩 `ncm_strlcpy`/`mkdir_p`/档位映射) | **63 PASS / 0 FAIL**,`-Wall -Wextra` 0 warning |
| `player_manager.c` / `player_flow_sim.c` 语法+告警检查(`gcc -O2 -Wall -Wextra -fsyntax-only`,临时 jsmn 头垫片) | 0 warning |
| `bench/player_flow_sim.c` 链接(真实 player_manager + stream_src + mem_watch + meow_cachegov) | 链接通过(运行需可写 `/tmp`,本机 Termux 不可写,见 39.4) |
| `test_ncm_tag_host.sh` 既有链路 | 未改 `ncm_tag.c`,未重跑(非本批改动面) |
### 39.4 诚实出入 / 遗留
- **本机缺 vendor(mbedtls/jsmn/minimp3/qrcodegen)与交叉工具链**,`build_core.sh`/`build_ncm.sh`/
`build_ncm_host.sh` 未执行;`test_ncm_core_host` 未重编(M5 已记录同一限制)。新语义用
`test/test_ncm_cache_host`(真实 core 源)全量覆盖,构建环境需补跑 core 全量回归。
- **`player_flow_sim` 未在本机运行**:其路径硬编码 `/tmp`(Termux 不可写),M5 后还缺若干链接桩
(本批已补齐);需在常规构建机跑 P1–P15 全量。
- **质量取舍(任务口径)**:命中下探后,hires 请求会直接播放已存的 exhigh 实体,不再像 §NEW 任务2
那样强制为「更高档」重下。这是「实际档命名 + 同名去重」的必然结果(用户已接受降级显示);
如需「换高档时重取」,应作为显式「重新下载」交互另开需求。
- **flac 头参数在响应时点未知**:落盘名以降级链生效档为上限,下载完成后由列表探测显示实际档;
服务端「声称 hires 实为 16bit」的病态情况,文件名会高于列表实测档(既有取舍,未引入回归)。
- **旧命名文件的跨档复用**:旧 `song_<id>_hires.mp3`(实际 exhigh)在 hires 请求下直接命中;若用户
切到「极高」档再播同名曲,会按实际档新落一份 `song_<id>_exhigh.mp3`(一次性重下,旧文件不删,
列表按 (id,实际档,ext) 去重只显示一行,旧文件由 LRU/清空自然回收)。未对旧名做向上探测扫描
(避免每次 miss 扫目录 + 读多份文件头);如需「旧名跨档零重下」,可作为后续优化加一次目录级兼容探测。
+54 -87
View File
@@ -499,7 +499,9 @@ static void song_gov_cleanup(song_src_t *ss)
meow_cachegov_drop_finished(ss->gov_final_path);
}
/* 缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 音质档位 → "song_<id>_<level>.<ext>"。
/* 请求缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 用户音质档位 → "song_<id>_<level>.<ext>"。
* 只用于**查找**(命中下探由 ncm_cache_lookup 处理);**写入**的正式名用响应实际档
* (M6:hires 请求被降级链落到 exhigh mp3 → 落盘为 song_<id>_exhigh.mp3,名实相符)。
* 同歌同档命中不重复下载;换档=新条目,旧档留给 LRU 自然淘汰。
* §20:键后缀用 ncm_api_level_key(sky 与 sky_c51 可区分)。 */
static void audio_cache_key(char *out, size_t sz, const char *id, int level)
@@ -507,19 +509,17 @@ static void audio_cache_key(char *out, size_t sz, const char *id, int level)
snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level));
}
/* §档位保真(REPORT §NEW 任务2②):显示档以响应实际格式/码率为准——
* - type=flac:采信降级链 eff(无损档链已保证 flac,br/采样由解码后定)。
* - type=mp3:绝不显示 Hi-Res 及以上(Hi-Res 需 >16bit/高采样,mp3 达不到)。按响应 br
* 参与判定:≤128k→标准;否则→极高 HQ(≤320k,仅降级链中间档「较高 192k」不进 UI,不上屏)。
* - br 未知:钳 chain_eff 到 ≤EXHIGH(保守不夸大)。
* 返回 UI 显示用档位(g_play.eff_level)。 */
static int eff_level_for_resp(const ncm_play_url_t *u, int chain_eff, int want)
/* 响应 → 实际档(写盘文件名 / 播放页显示 / 已缓存列表三处同源,core ncm_cache_actual_level):
* 容器 type 权威;mp3 按响应 br(≤128k→标准,否则极高,永不高于极高);flac 取降级链
* 生效档 eff(头参数下载后才能测,落盘名不夸大)。只降不升(min(请求档, 实际档))。
* 返回实际档(g_play.eff_level 用)。 */
static int actual_level_for_resp(const ncm_play_url_t *u, const char *ext, int chain_eff, int want)
{
if (u && ncm_streq(u->type, "flac")) return (chain_eff >= 0) ? chain_eff : want;
int br = u ? u->br : 0;
if (br > 0) return (br <= 128000) ? NCM_LEVEL_STANDARD : NCM_LEVEL_EXHIGH; /* 按实际码率 */
int e = (chain_eff >= 0) ? chain_eff : want;
return (e < NCM_LEVEL_EXHIGH) ? e : NCM_LEVEL_EXHIGH; /* 未知:钳到 ≤EXHIGH */
ncm_audio_info_t ai;
memset(&ai, 0, sizeof ai);
ncm_strlcpy(ai.container, ext ? ext : "", sizeof ai.container);
if (u && u->br > 0 && ncm_streq(ai.container, "mp3")) ai.bitrate_kbps = u->br / 1000;
return ncm_cache_actual_level(want, &ai, chain_eff);
}
/* 同曲重播护栏(§25 切音质后重播同一曲,替代 §18 的 still_current):仅 cur/state 不够——
@@ -540,7 +540,7 @@ static int still_intent(int idx, int my_seq)
* 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试);
* DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。
* *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const char *key, int want,
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, int want,
song_src_t *ss, char *path, size_t psz, int64_t *bytes,
int *real_err)
{
@@ -553,15 +553,21 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
account_vip(), s->fee, want, &u, &eff);
if (rc != NCM_OK) return rc;
if (!u.url[0]) return NCM_ERR_NOTFOUND;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff_level_for_resp(&u, eff, want); /* §2②:显示档按实际格式/码率 */
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
const char *ext = "mp3";
if (ncm_streq(u.type, "flac")) ext = "flac";
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
ncm_strlcpy(ss->ext, ext, sizeof ss->ext);
/* M6:写盘名用**响应实际档**(不是请求档)。hires 请求被降级链落到 exhigh mp3
* → song_<id>_exhigh.mp3,文件名与内容相符;同档再播按实际档键直接命中。
* .part 续传也用同一实际档键(同名才可续传/commit)。 */
int actual = actual_level_for_resp(&u, ext, eff, want);
char akey[64];
audio_cache_key(akey, sizeof akey, id, actual);
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = actual; /* 显示档 = 实际档(与文件名/列表同源) */
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 文件内 tag 字段(下载完整后由 dl_thread 写入;空字段不写)。 */
memset(&ss->tag, 0, sizeof ss->tag);
ncm_strlcpy(ss->tag.title, s->name, sizeof ss->tag.title);
@@ -574,17 +580,17 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
/* 断点续传起点(§5.2):.part 残体存在且本次 URL 的 size > 残体大小 → 续传:
* 追加打开 .part(O_APPEND,保留已写字节),written 从 part_size 起算(读端秒开播)。 */
uint64_t part_size = 0;
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, key, ext, &part_size) == NCM_OK) &&
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, akey, ext, &part_size) == NCM_OK) &&
part_size > 0;
int resuming = 0;
if (have_part && (long)part_size < u.size) {
rc = ncm_cache_open_resume(&g_st.ctx.cache, key, ext, &ss->w);
rc = ncm_cache_open_resume(&g_st.ctx.cache, akey, ext, &ss->w);
if (rc == NCM_OK) { ss->resume_base = part_size; resuming = 1; }
else have_part = 0; /* 残体打开失败:转整首 */
}
if (!resuming) {
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, key, ext); /* 残体过期(size 不符):删 */
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->w);
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, akey, ext); /* 残体过期(size 不符):删 */
rc = ncm_cache_open(&g_st.ctx.cache, akey, ext, &ss->w);
if (rc != NCM_OK) return rc;
}
if (stream_src_open_stream_at(&ss->src, ncm_cache_writer_tmp_path(ss->w),
@@ -596,8 +602,8 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
ss->total = (u.size > 0) ? u.size : 0;
/* §NEW 任务1(SIGSEGV 定位):下载链路诊断日志——起播即留档,断流/崩溃后从 log 回溯。 */
meow_log(MEOW_LOG_INFO,
"play: dl start id=%s lv=%s eff=%s size=%lld resume=%s url=%.56s",
id, ncm_api_level_key(want), ncm_api_level_key(eff >= 0 ? eff : want),
"play: dl start id=%s req=%s actual=%s size=%lld resume=%s url=%.56s",
id, ncm_api_level_key(want), ncm_api_level_key(actual),
(long long)u.size, resuming ? "yes" : "no", u.url);
/* 上限:Hi-Res flac 单曲可达数百 MB,flac 上限 1GB;mp3 维持 64MB。
* size 可信时按 size 限幅(防超额),未知按格式默认上限(续传 body ≤ size 亦安全)。 */
@@ -665,7 +671,7 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
char key[64];
audio_cache_key(key, sizeof key, id, g_level);
/* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名请求档)与实际扩展名直接
/* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名档位)与实际扩展名直接
* 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」
* 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */
if (appstate_get_queue() == NCM_QUEUE_CACHE) {
@@ -690,74 +696,35 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
return NCM_ERR_NOENT;
}
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
/* 缓存命中 → 静态 stream_src。查找顺序(档位下探在 ncm_cache_lookup 内):
* ① 请求档精确(flac 优先于 mp3)
* ② 请求档以下逐档下探——缓存名已按**实际档**命名,hires 请求命中已存的
* song_<id>_exhigh.mp3(降级产物不重下)
* ③ 历史无档位键 song_<id>.mp3 / 双点名(老卡存量:读可用,**不强制迁移**) */
int hit = 0;
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK) {
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK ||
ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
hit = 1;
} else if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
/* §19 前存量:无档位后缀的 song_<id>[.]mp3(含历史双点名 song_<id>..mp3)。
* §NEW 任务5:命中即**迁移重命名**到当前档位键 song_<id>_<level>.mp3(下方 hit==2
* 分支做 rename,不重下),之后不再回退旧键。 */
hit = 2;
}
if (hit) {
struct stat st;
if (stat(path, &st) != 0) return NCM_ERR_NOENT;
if (hit == 2) {
/* §NEW 任务5:存量旧键(无档位后缀 song_<id>.mp3 / 历史双点 song_<id>..mp3)
* 迁移重命名到当前档位键 song_<id>_<g_level>.<ext>(**不重下**,仅改名)。
* 目标已存在(该档已有正式条目)则删旧;迁移失败回退旧路径照常播。 */
char nk[64], np[512];
audio_cache_key(nk, sizeof nk, id, g_level);
const char *dot = strrchr(path, '.');
const char *ext = dot ? dot + 1 : "mp3";
if (ncm_cache_path(&g_st.ctx.cache, nk, ext, np, sizeof np) == NCM_OK) {
struct stat nst;
if (stat(np, &nst) == 0) unlink(path); /* 目标已在(或并发建成):删旧双点/旧名 */
else rename(path, np);
ncm_strlcpy(path, np, psz); /* 迁移后即用新路径(无论 rename 成败,
* 覆盖到同一目标;失败则下次会再试) */
if (stat(np, &st) != 0) return NCM_ERR_NOENT;
}
}
/* §NEW 任务2:静默降级时代产物修复(用户实锤:hires 档键下存着 mp3,播放命中旧档)。
* 用户档 > EXHIGH(无损/更高)且缓存实体是 .mp3 = flac 门禁前把 exhigh 的 mp3 写进了
* 无损域键(当年降级产物归用户档键)。迁移重命名到 exhigh 键 song_<id>_exhigh.mp3
* (选「极高」档时仍可用;目标已存在则删旧),然后本档视为未命中走下载——
* 311 的 flac 门禁会拿到真无损或正确降级。放在 hit==2 迁移块之后、静态流打开之前。 */
const char *mdot = strrchr(path, '.');
if (g_level > NCM_LEVEL_EXHIGH && mdot && ncm_streq(mdot, ".mp3")) {
char kh[64], ph[512];
audio_cache_key(kh, sizeof kh, id, NCM_LEVEL_EXHIGH);
if (ncm_cache_path(&g_st.ctx.cache, kh, "mp3", ph, sizeof ph) == NCM_OK) {
struct stat hst;
if (stat(ph, &hst) == 0) unlink(path); /* exhigh 目标已在:删旧(降级产物) */
else rename(path, ph);
meow_log(MEOW_LOG_INFO,
"play: stale-degraded id=%s lv=%s entity=mp3 -> exhigh key; redownload",
id, ncm_api_level_key(g_level));
hit = 0; /* 本档不符 → 走下载(flac 门禁取真档) */
}
}
if (hit) {
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
return NCM_ERR_IO;
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
/* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原):
* mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。
* (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。)
* 注:mp3 + 用户档 > 极高 的组合已被上方迁移块拦截走下载,此处仅存防御。 */
const char *dot = strrchr(path, '.');
int eff = g_level;
if (dot && ncm_streq(dot, ".mp3") && g_level > NCM_LEVEL_EXHIGH)
eff = NCM_LEVEL_EXHIGH;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
/* 生效/显示档:新命名文件名即实际档;旧命名(请求档)向下钳——mp3 实体不可能高于
* 极高,故旧 song_<id>_hires.mp3 标注 EXHIGH;旧无档位键条目按用户档同规则钳。
* 只改显示不改文件名(老卡文件继续可播/可删/可淘汰,免迁移)。 */
const char *dot = strrchr(path, '.');
int eff = ncm_cache_level_from_path(path);
if (eff < 0) eff = g_level;
if (dot && ncm_streq(dot, ".mp3") && eff > NCM_LEVEL_EXHIGH) eff = NCM_LEVEL_EXHIGH;
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
return NCM_ERR_IO;
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
/* fee 门禁:VIP 才能听(1=VIP 歌曲 / 4=付费数字专辑)且非会员 → 不请求 url。
@@ -781,7 +748,7 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
if (g_app_quit || !still_intent(idx, seq)) return NCM_ERR_IO;
}
int real_err = NCM_ERR_NET;
int rc = song_download_attempt(s, idx, seq, key, want, ss, path, psz, bytes, &real_err);
int rc = song_download_attempt(s, idx, seq, want, ss, path, psz, bytes, &real_err);
if (rc == NCM_OK) return NCM_OK;
if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */
return rc; /* DL_ABORTED / 硬错误:不重试 */
+98 -22
View File
@@ -14,8 +14,8 @@
* P12 切音质后重播同一曲(§25):同 idx 在播时再 play —— 修前被解码主循环忽略
* (旧曲继续出声、state 恒 BUFFER/「缓冲中」,收尾被外层当播完顺延下一首);
* 修后 seq 变化即重启换档,状态回 PLAYING、新档位下载、eff_level 回填。
* P13 陈旧 hires.mp3(flac 门禁前静默降级产物,hires 键下存 mp3):song_local_file
* 迁移重命名到 exhigh 键(song_<id>_exhigh.mp3)+ 本档视为 miss 走下载(§NEW 任务2)。
* P13 旧命名 hires.mp3(卡上存量:hires 键下存 mp3):M6 起按文件名档位+容器钳顶显示
* EXHIGH 直接播放,不重下、不迁移(老卡文件继续可播/可删/可淘汰)。
* P14 歌单里点「正在播的同曲」→ 只聚焦不重播(任务3 re-entry,同曲 id 判据)。
* P15 跨歌单切换后同一队列下标点歌 → 不被同曲护栏吞,起播(Bug2 回归:真机日志
* `play: re-enter current idx=0 state=2 -> no restart`,新歌永不播)。
@@ -87,12 +87,22 @@ int appstate_queue_song(int idx, ncm_song_t *out) { return appstate_song_copy(id
* sim 恒歌单窗口队列、搜索数组为空。appstate.c 未链入,两符号此处提供桩。 */
int appstate_get_queue(void) { return NCM_QUEUE_PLAYLIST; }
int appstate_search_song(int i, ncm_song_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
/* M5 已缓存列表 / 文件内 tag:本 sim 不链 appstate.c、ncm_tag.c(缓存列表语义由 M5/M6 的
* core 测试覆盖),仅提供链接桩避免 gc-sections 之外的空洞。 */
int appstate_cache_list_load(void) { return 0; }
int appstate_cache_count(void) { return 0; }
int appstate_cache_item(int i, ncm_cache_item_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
int appstate_cache_song(int i, ncm_song_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
int appstate_cache_remove(int i) { (void)i; return NCM_ERR_ARG; }
void appstate_cache_clear(void) { }
int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t *m)
{ (void)path; (void)ext; (void)m; return NCM_ERR_ARG; } /* 降级为裸流(生产路径容忍) */
/* ---- 缓存/网络 stub ----
* g_cache_ok=1 → lookup 命中(给真实存在的 /tmp 文件,免下载);
* 否则走下载:song_url(g_url_rc 可注入失败) → http_download(g_dl_delay_ms 模拟慢) → commit。 */
static int g_cache_ok = 1;
static char g_cache_real[600]; /* g_cache_ok==2:lookup 命中返回此真实路径(P13 陈旧 mp3 迁移用) */
static char g_cache_real[600]; /* g_cache_ok==2:lookup 命中返回此真实路径(P13 旧命名命中用) */
static int g_url_rc = NCM_OK;
static int g_dl_delay_ms = 0;
static int g_downloads; /* http_download 调用数(song_url 请求放大检测) */
@@ -138,7 +148,7 @@ static void pf_ensure_sim_mp3(void)
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz)
{
(void)c; (void)id;
if (g_cache_ok == 2) { /* P13 真实命中模式:仅 mp3 命中(stale hires 实体是 mp3) */
if (g_cache_ok == 2) { /* P13 真实命中模式:仅 mp3 命中(旧命名 hires.mp3 实体是 mp3) */
if (ext && strcmp(ext, "mp3") == 0 && g_cache_real[0]) {
snprintf(out, out_sz, "%s", g_cache_real);
return NCM_OK;
@@ -195,6 +205,59 @@ int ncm_cache_path(const ncm_cache_t *c, const char *id, const char *ext, char *
(c && c->dir[0]) ? c->dir : "/tmp", id ? id : "", e ? e : "bin");
return NCM_OK;
}
/* M6:player_manager 用 core 的实际档判定/路径档位解析(本 sim 不链 core/ncm_cache.c,
* 此处按 core 同规则提供桩——命名规则本身由 test/test_ncm_cache_host 全量断言)。 */
int ncm_cache_actual_level(int req, const ncm_audio_info_t *ai, int resp_level)
{
int cap = -1, v;
if (resp_level < 0) resp_level = req;
if (ai && ai->container[0]) {
if (strcmp(ai->container, "flac") == 0) {
if (ai->bits_per_sample <= 0 || ai->sample_rate <= 0) cap = -1;
else if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS;
else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER;
else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT;
else cap = NCM_LEVEL_HIRES;
} else if (strcmp(ai->container, "mp3") == 0) {
if (ai->bitrate_kbps > 128) cap = NCM_LEVEL_EXHIGH;
else if (ai->bitrate_kbps > 0) cap = NCM_LEVEL_STANDARD;
}
}
if (ai && strcmp(ai->container, "mp3") == 0) {
if (cap < 0) cap = NCM_LEVEL_EXHIGH;
if (resp_level >= 0 && resp_level < cap) cap = resp_level;
} else if (cap < 0) {
cap = resp_level;
} else if (resp_level >= 0 && resp_level < cap) {
cap = resp_level;
}
if (req < 0) v = (cap < 0) ? -1 : cap;
else if (cap < 0) v = req;
else v = (req < cap) ? req : cap;
return v;
}
int ncm_cache_level_from_path(const char *path)
{
const char *base, *dot, *us = NULL, *us2 = NULL;
char key[20];
size_t n;
int lv;
if (!path || !path[0]) return -1;
base = strrchr(path, '/'); base = base ? base + 1 : path;
dot = strrchr(base, '.'); if (!dot) dot = base + strlen(base);
for (const char *p = base; p < dot; p++) if (*p == '_') { us2 = us; us = p; }
if (!us || us + 1 >= dot) return -1;
n = (size_t)(dot - us - 1);
if (n >= sizeof key) return -1;
memcpy(key, us + 1, n); key[n] = 0;
lv = ncm_api_level_from_key(key);
if (lv >= 0 || !us2 || us2 + 1 >= us - 1) return lv;
n = (size_t)(dot - us2 - 1);
if (n >= sizeof key) return -1;
memcpy(key, us2 + 1, n); key[n] = 0;
return ncm_api_level_from_key(key);
}
int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int level,
ncm_play_url_t *out, int *out_level)
@@ -310,6 +373,20 @@ const char *ncm_api_level_key(int level)
default: return "standard";
}
}
int ncm_api_level_from_key(const char *key)
{
if (!key || !key[0]) return -1;
if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND;
if (!strcmp(key, "sky")) return NCM_LEVEL_SKY;
if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER;
if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT;
if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES;
if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS;
if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH;
if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER;
if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD;
return -1;
}
/* ---- 音频输出 stub:g_write_fail 注入写失败 ---- */
static int g_write_fail;
@@ -467,8 +544,8 @@ int main(void)
g_url_req_level, NCM_LEVEL_EXHIGH);
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"eff_level=%d (expect EXHIGH,UI 标「已降级至 极高 HQ」)", player_manager_eff_level());
CHECK(strcmp(g_last_key, "1000_sky_c51") == 0,
"cache key=%s (expect 1000_sky_c51 = id+用户档,降级产物归用户档键)", g_last_key);
CHECK(strcmp(g_last_key, "1000_exhigh") == 0,
"cache key=%s (expect 1000_exhigh = 实际档键,非用户档 sky_c51)", g_last_key);
/* SVIP:同一用户档不钳顶,直达 SURROUND。
* §21 时序:commit 移到 song0 曲尾 dispose(边下边播)——先 stop 阻断外层自动顺延
* 到 song1 的竞态,再等 commit 落地后才切缓存开关,否则 commit stub 会把
@@ -500,8 +577,7 @@ int main(void)
player_manager_error());
CHECK(g_url_calls == 0, "song_url 零请求(省一次请求,防风控;calls=%d)", g_url_calls);
/* 已缓存的 VIP 曲:门禁不拦(缓存优先,NCM_CACHE_DESIGN §2.4)。
* 注:把档位降回 EXHIGH——缓存 stub 的「命中」恒是 mp3;用户档若仍 SURROUND(>EXHIGH),
* 会被 §NEW 任务2 当作静默降级产物迁移/重下,与「绕过门禁」的本意不符(P13 单独测迁移)。 */
* 档位设 EXHIGH:stub 缓存恒返回 mp3(mp3 实际档不高于极高),与条目标注一致。 */
player_manager_set_quality(NCM_LEVEL_EXHIGH, 10);
g_cache_ok = 1;
player_manager_play(2);
@@ -595,8 +671,8 @@ int main(void)
CHECK(wait_state(NCM_PC_PLAYING, 4000),
"re-tap same idx -> back to PLAYING (expect 换档开播,非卡 缓冲中)");
CHECK(g_downloads >= 1, "re-tap started new-quality download (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_lossless") == 0,
"re-tap cache key=%s (expect 1000_lossless = 新档位)", g_last_key);
CHECK(strcmp(g_last_key, "1000_exhigh") == 0,
"re-tap cache key=%s (expect 1000_exhigh = 实际档,非会员钳顶后 320k mp3)", g_last_key);
/* sim 账号非会员 → lossless 被 §20 钳顶到 EXHIGH;关键是 eff_level 已按新意图回填
* (UI 据此显示实际生效档,不再停 BUFFER/「缓冲中」)。 */
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
@@ -604,30 +680,30 @@ int main(void)
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
printf("== P13: 陈旧 hires.mp3(静默降级产物)-> exhigh 迁移 + 本档 miss 重下(§NEW 任务2)==\n");
printf("== P13: 旧命名 hires.mp3(实际 exhigh)-> 命中播放 + 实际档显示,不重下不迁移(M6)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_delay_ms = 0; g_dl_fail = 0;
g_vip = NCM_VIP_NONE; g_writes = 0; g_downloads = 0; g_url_calls = 0;
g_last_key[0] = 0; g_last_ext[0] = 0; g_url_type = "mp3";
/* 用户档 = Hi-Res(>EXHIGH),缓存实体是一首 mp3 = flac 门禁前把 exhigh 的 mp3 写进 hires
* 键的「静默降级」产物。须被迁移到 exhigh 键(song_1000_exhigh.mp3),本档视为 miss 走下载。 */
unlink("/tmp/song_1000_exhigh.mp3"); /* 保证 exhigh 目标不存在(走 rename 而非删旧) */
/* 用户档 = Hi-Res(>EXHIGH),卡上存量是旧命名 song_1000_hires.mp3,实体是 320k mp3。
* M6:文件名语义改为「实际档」后,此条目按文件名档位+容器钳顶显示 EXHIGH,直接播放;
* 不迁移、不重下(旧文件继续可播/可删/可淘汰)。 */
FILE *hf = fopen("/tmp/pf_hires.mp3", "wb");
if (hf) { fputs("DEGRADED", hf); fclose(hf); }
snprintf(g_cache_real, sizeof g_cache_real, "/tmp/pf_hires.mp3");
g_cache_ok = 2; /* lookup 命中返回 g_cache_real(仅 mp3 键) */
player_manager_set_quality(NCM_LEVEL_HIRES, 10);
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "stale hires.mp3 redownloaded -> plays");
CHECK(g_downloads >= 1, "stale mp3 triggered redownload (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_hires") == 0,
"redownload cache key=%s (expect 1000_hires=用户档键)", g_last_key);
CHECK(strcmp(g_last_ext, "mp3") == 0, "redownload ext=%s (请求档为 mp3,维持原身份)", g_last_ext);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "legacy hires.mp3 hit -> plays directly");
CHECK(g_downloads == 0, "legacy entry hit -> no redownload (downloads=%d)", g_downloads);
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"eff_level=%d (expect EXHIGH:mp3 实体不可能高于极高)", player_manager_eff_level());
struct stat mm;
CHECK(stat("/tmp/song_1000_exhigh.mp3", &mm) == 0,
"exhigh migrated file exists (选「极高」档仍可用)");
CHECK(stat("/tmp/pf_hires.mp3", &mm) != 0, "stale hires.mp3 source gone (已迁走)");
CHECK(stat("/tmp/pf_hires.mp3", &mm) == 0 && mm.st_size == 8,
"legacy file untouched (no migration)");
CHECK(stat("/tmp/song_1000_exhigh.mp3", &mm) != 0,
"no renamed copy created (old card files need no migration)");
g_cache_ok = 0; g_cache_real[0] = 0;
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# build_ncm_cache_host.sh — 宿主单测:core/ncm_cache.c(M6 实际档命名 / 命中下探 / 旧名兼容 / LRU)。
# ncm_cache.c 依赖 ncm_tag.c(纯 libc)+ ncm_common/store/api 的少量符号,后者由测试文件内联桩提供,
# 故不依赖 mbedtls/jsmn/vendor(vendor 缺失时也能跑,$TMPDIR 纪律见 test 文件头)。
cd "$(dirname "$0")"
CC_HOST=${CC_HOST:-gcc}
OUT="test/test_ncm_cache_host"
$CC_HOST -O2 -Wall -Wextra -Icore \
core/ncm_cache.c core/ncm_tag.c test/test_ncm_cache.c -o "$OUT" \
|| { echo "build test_ncm_cache FAILED"; exit 1; }
echo "build OK: $OUT"
echo "run: ./$OUT"
+141 -32
View File
@@ -148,10 +148,12 @@ int ncm_cache_path(const ncm_cache_t *c, const char *id, const char *ext,
return (n >= 0 && (size_t)n < out_sz) ? NCM_OK : NCM_ERR_FULL;
}
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext,
char *out, size_t out_sz) {
/* 单键精确查找(含 §19 历史双点名 `song_<key>..<ext>` → 单点名的迁移重命名)。
* 命中:out=正式单点名路径并刷新 mtime(LRU),返回 NCM_OK;未命中 NCM_ERR_NOENT。 */
static int cache_lookup_exact(const ncm_cache_t *c, const char *key, const char *ext,
char *out, size_t out_sz) {
char path[NCM_CACHE_DIR_MAX + 32];
if (ncm_cache_path(c, id, ext, path, sizeof path) != 0) return NCM_ERR_FULL;
if (ncm_cache_path(c, key, ext, path, sizeof path) != 0) return NCM_ERR_FULL;
struct stat st;
if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) {
/* §19 温和兼容:历史双点名 `song_<id>..<ext>`(旧版 ext 带前导点所致)。
@@ -160,27 +162,23 @@ int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext,
const char *e = ext;
if (e && e[0] == '.') e++;
const char *ee = e ? e : "bin";
const char *base = cache_base_dir(c, id);
int n = snprintf(path, sizeof path, "%s/song_%s..%s", base, id, ee);
const char *base = cache_base_dir(c, key);
int n = snprintf(path, sizeof path, "%s/song_%s..%s", base, key, ee);
if (n < 0 || (size_t)n >= sizeof path) return NCM_ERR_FULL;
if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) {
/* §NEW 分目录兜底:迁移前旧布局双点名可能仍躺在缓存根(init 迁移后应已在子目录;
* 这里双点兼容:子目录先查、根目录再查,保证任何存量双点都被迁移修正)。 */
n = snprintf(path, sizeof path, "%s/song_%s..%s", c->dir, id, ee);
n = snprintf(path, sizeof path, "%s/song_%s..%s", c->dir, key, ee);
if (n < 0 || (size_t)n >= sizeof path) return NCM_ERR_FULL;
if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) return NCM_ERR_NOENT;
}
char fixed[NCM_CACHE_DIR_MAX + 32];
if (ncm_cache_path(c, id, ee, fixed, sizeof fixed) == NCM_OK) {
if (ncm_cache_path(c, key, ee, fixed, sizeof fixed) == NCM_OK) {
struct stat fst;
if (stat(fixed, &fst) == 0) unlink(path); /* 目标已(在/被并发建):删旧双点 */
else rename(path, fixed);
if (stat(fixed, &st) == 0 && S_ISREG(st.st_mode)) {
ncm_strlcpy(path, fixed, sizeof path);
} else {
ncm_strlcpy(path, fixed, sizeof path); /* 迁移失败(权限等):回到固定路径 */
return NCM_ERR_NOENT;
}
ncm_strlcpy(path, fixed, sizeof path); /* 迁移失败(权限等):回到固定路径 */
if (stat(fixed, &st) != 0 || !S_ISREG(st.st_mode)) return NCM_ERR_NOENT;
}
}
utime(path, NULL); /* 刷新最后访问(按 mtime LRU) */
@@ -188,6 +186,51 @@ int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext,
return NCM_OK;
}
/* 档位下探表(高→低;DOLBY 与降级链/UI 同口径不在表内)。缓存文件按**实际档**命名(M6),
* 故高档请求可下探命中已存的低档实体:hires 请求命中 song_<id>_exhigh.mp3(降级产物不重下)。 */
static const int k_level_ladder[] = {
NCM_LEVEL_SURROUND, NCM_LEVEL_SKY, NCM_LEVEL_JYMASTER, NCM_LEVEL_JYEFFECT,
NCM_LEVEL_HIRES, NCM_LEVEL_LOSSLESS, NCM_LEVEL_EXHIGH, NCM_LEVEL_HIGHER,
NCM_LEVEL_STANDARD,
};
#define LEVEL_LADDER_N ((int)(sizeof k_level_ladder / sizeof k_level_ladder[0]))
/* 解析歌曲缓存键 `<数字id>_<档位键>` → 档位枚举(id 回填)。非歌曲键/无档位 → -1。 */
static int split_level_key(const char *key, char *id, size_t id_sz) {
const char *p = key;
size_t n = 0;
if (!key || !key[0]) return -1;
while (p[n] >= '0' && p[n] <= '9' && n + 1 < id_sz) { id[n] = p[n]; n++; }
if (n == 0 || p[n] != '_') return -1;
id[n] = 0;
return ncm_api_level_from_key(p + n + 1);
}
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext,
char *out, size_t out_sz) {
int rc;
if (!c || !id || !out) return NCM_ERR_ARG;
rc = cache_lookup_exact(c, id, ext, out, out_sz);
if (rc == NCM_OK || rc == NCM_ERR_FULL) return rc;
if (id_is_cover(id)) return NCM_ERR_NOENT; /* 封面键无档位语义 */
char sid[24];
int lv = split_level_key(id, sid, sizeof sid);
if (lv < 0 || lv > NCM_LEVEL_SURROUND) return NCM_ERR_NOENT;
int idx = -1;
for (int i = 0; i < LEVEL_LADDER_N; i++)
if (k_level_ladder[i] == lv) { idx = i; break; }
if (idx < 0) return NCM_ERR_NOENT;
for (int i = idx + 1; i < LEVEL_LADDER_N; i++) { /* 只降不升 */
char k2[48];
int n = snprintf(k2, sizeof k2, "%s_%s", sid, ncm_api_level_key(k_level_ladder[i]));
if (n < 0 || (size_t)n >= sizeof k2) continue;
rc = cache_lookup_exact(c, k2, ext, out, out_sz);
if (rc == NCM_OK) return NCM_OK;
if (rc == NCM_ERR_FULL) return rc;
}
return NCM_ERR_NOENT;
}
struct ncm_cache_writer {
char final_path[NCM_CACHE_DIR_MAX + 64];
char tmp_path[NCM_CACHE_DIR_MAX + 80];
@@ -465,10 +508,11 @@ int ncm_cache_clear(const ncm_cache_t *c) {
}
/* ---- 「已缓存列表」:扫描/删除(键=歌曲ID+档位,NCM_CACHE_DESIGN §2.5)----
* 文件名 song_<id>_<请求档位>.<实际格式>;**档位段只是请求档**,实际档由文件头探测得出
* (真机 song_<id>_hires.mp3 = 无损请求被降级链落到 exhigh mp3)。 */
* 文件名 song_<id>_<档位>.<实际格式>:M6 起**档位段=实际档**(响应档),如无损请求被
* 降级链落到 exhigh mp3 → song_<id>_exhigh.mp3。旧版缓存名的档位段仍是请求档
* (真机 song_<id>_hires.mp3),读取/删除/LRU 一律按文件名原样兼容,不强制迁移。 */
/* 解析条目名 → id/请求档位键/实际扩展名。song_ 后必须是数字 id(排除 song_cov* 封面)。
/* 解析条目名 → id/文件名档位键/实际扩展名。song_ 后必须是数字 id(排除 song_cov* 封面)。
* 返回 0=是歌曲条目。 */
static int parse_item_name(const char *nm, ncm_cache_item_t *it)
{
@@ -493,23 +537,70 @@ static int parse_item_name(const char *nm, ncm_cache_item_t *it)
return 0;
}
/* 请求档 + 实际容器参数 → 实际档(只降不升:显示「已降级至 X」语义)。 */
static int actual_level_of(int req, const ncm_audio_info_t *ai)
/* 容器探测结果 → 该容器可承载的档位上界(-1=无法判定,由响应档/请求档决定)。 */
static int container_level_cap(const ncm_audio_info_t *ai)
{
int cap;
if (!ai || !ai->container[0]) return req; /* 探测失败:保留请求档 */
if (!ai || !ai->container[0]) return -1;
if (strcmp(ai->container, "flac") == 0) {
if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS;
else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER; /* 192k/24 */
else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT; /* 96k/24 */
else cap = NCM_LEVEL_HIRES; /* 48k/24 */
} else if (strcmp(ai->container, "mp3") == 0) {
cap = (ai->bitrate_kbps > 128) ? NCM_LEVEL_EXHIGH : NCM_LEVEL_STANDARD;
} else {
return req;
if (ai->bits_per_sample <= 0 || ai->sample_rate <= 0) return -1; /* 仅知容器 */
if (ai->bits_per_sample <= 16) return NCM_LEVEL_LOSSLESS;
if (ai->sample_rate > 96000) return NCM_LEVEL_JYMASTER; /* 192k/24 */
if (ai->sample_rate > 48000) return NCM_LEVEL_JYEFFECT; /* 96k/24 */
return NCM_LEVEL_HIRES; /* 48k/24 */
}
if (req < 0) return cap;
return (req < cap) ? req : cap;
if (strcmp(ai->container, "mp3") == 0) {
if (ai->bitrate_kbps > 128) return NCM_LEVEL_EXHIGH;
if (ai->bitrate_kbps > 0) return NCM_LEVEL_STANDARD;
return -1; /* 码率未知:交 resp_level 钳顶 */
}
return -1;
}
int ncm_cache_actual_level(int req, const ncm_audio_info_t *ai, int resp_level)
{
int cap = container_level_cap(ai);
int v;
if (resp_level < 0) resp_level = req;
if (ai && strcmp(ai->container, "mp3") == 0) {
if (cap < 0) cap = NCM_LEVEL_EXHIGH; /* mp3 永不高于极高 */
if (resp_level >= 0 && resp_level < cap) cap = resp_level;
} else if (cap < 0) {
cap = resp_level; /* 头参数未知:信响应/降级链生效档 */
} else if (resp_level >= 0 && resp_level < cap) {
cap = resp_level;
}
if (req < 0) v = (cap < 0) ? -1 : cap;
else if (cap < 0) v = req;
else v = (req < cap) ? req : cap; /* 只降不升 */
return v;
}
int ncm_cache_level_from_path(const char *path)
{
const char *base, *dot, *us = NULL, *us2 = NULL;
char key[20];
size_t n;
int lv;
if (!path || !path[0]) return -1;
base = strrchr(path, '/');
base = base ? base + 1 : path;
dot = strrchr(base, '.');
if (!dot) dot = base + strlen(base);
for (const char *p = base; p < dot; p++)
if (*p == '_') { us2 = us; us = p; }
if (!us || us + 1 >= dot) return -1;
n = (size_t)(dot - us - 1);
if (n >= sizeof key) return -1;
memcpy(key, us + 1, n);
key[n] = 0;
lv = ncm_api_level_from_key(key);
if (lv >= 0 || !us2 || us2 + 1 >= us - 1) return lv;
/* 档位键本身带下划线(sky_c51):再带上前一段试一次 */
n = (size_t)(dot - us2 - 1);
if (n >= sizeof key) return -1;
memcpy(key, us2 + 1, n);
key[n] = 0;
return ncm_api_level_from_key(key);
}
static int cmp_item_mtime_desc(const void *a, const void *b)
@@ -559,8 +650,11 @@ int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max)
slot = oldest;
}
memset(&ai, 0, sizeof ai);
it.actual_level = actual_level_of(ncm_api_level_from_key(it.level),
(ncm_audio_probe_file(p, &ai) == NCM_OK) ? &ai : NULL);
/* 文件名档位 + 文件头实测 → 实际档(新命名两者一致;旧请求档命名由探测纠正,
* 如 song_<id>_hires.mp3 实体是 320k mp3 → actual=exhigh)。 */
it.actual_level = ncm_cache_actual_level(ncm_api_level_from_key(it.level),
(ncm_audio_probe_file(p, &ai) == NCM_OK) ? &ai : NULL,
-1);
if (ncm_tag_read_file(p, &tag) == NCM_OK) { /* 文件内 tag 优先(外部播放器同源) */
ncm_strlcpy(it.name, tag.title, sizeof it.name);
ncm_strlcpy(it.artist, tag.artist, sizeof it.artist);
@@ -570,6 +664,21 @@ int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max)
}
closedir(d);
qsort(out, (size_t)n, sizeof *out, cmp_item_mtime_desc);
/* 同曲同实际档去重展示:旧命名(请求档)与新命名(实际档)可能同时存在
* (如 song_<id>_hires.mp3 与 song_<id>_exhigh.mp3 都是 320k mp3),只留最近访问的一条。 */
{
int w = 0;
for (int i = 0; i < n; i++) {
int dup = 0;
for (int j = 0; j < w; j++) {
if (strcmp(out[j].id, out[i].id) == 0 &&
out[j].actual_level == out[i].actual_level &&
strcmp(out[j].ext, out[i].ext) == 0) { dup = 1; break; }
}
if (!dup) { if (w != i) out[w] = out[i]; w++; }
}
n = w;
}
return n;
}
+28 -5
View File
@@ -11,6 +11,9 @@
#include <stddef.h>
#include <stdint.h>
#include "ncm_api.h" /* NCM_LEVEL_*(实际档位判定/文件名档位解析) */
#include "ncm_tag.h" /* ncm_audio_info_t(容器/位深/码率探测结果) */
#ifdef __cplusplus
extern "C" {
#endif
@@ -47,10 +50,28 @@ int ncm_cache_path(const ncm_cache_t *c, const char *id, const char *ext,
char *out, size_t out_sz);
/* 查找已缓存文件。命中:写 out 路径并刷新 mtime,返回 NCM_OK。未命中:NCM_ERR_NOENT。
* §19:ext 前导点被忽略(新旧调用方同路径);单点 miss 时回退历史双点名 `song_<id>..<ext>`。 */
* §19:ext 前导点被忽略(新旧调用方同路径);单点 miss 时回退历史双点名 `song_<id>..<ext>`。
*
* 档位下探(M6):键形如 `<歌曲id>_<档位键>` 时,请求档精确 miss 后**只降不升**逐档下探
* (hires → lossless → exhigh → higher → standard)。缓存文件已按**实际档**命名,故
* hires 请求能命中已存的 song_<id>_exhigh.mp3(降级产物不重下);低档请求不会命中高档实体。 */
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext,
char *out, size_t out_sz);
/* 实际档位判定(**单一事实源**:写缓存名 / 已缓存列表显示 / 命中档位推断共用)。
* req 请求档 NCM_LEVEL_*(<0=未知)
* ai 容器探测结果(可为 NULL;flac 需 bits/sample_rate 才按头判档)
* resp_level 响应/降级链生效档(<0=未知;flac 响应头参数未知时以它为准)
* 返回 min(req, 容器可承载档, resp_level)——只降不升:
* mp3 码率 ≤128k → standard,>128k → exhigh(mp3 永不高于 exhigh);
* flac 16bit/44.1k → lossless,24bit/48k → hires,96k → jyeffect,192k → jymaster。 */
int ncm_cache_actual_level(int req, const ncm_audio_info_t *ai, int resp_level);
/* 从缓存文件路径解析**文件名档位段**(song_<id>_<档位>.<ext> 的档位;取扩展名前最后一个
* '_' 之后的部分)。新命名=实际档,旧命名=请求档(可能存在降级,调用方需再按容器钳顶)。
* 返回 NCM_LEVEL_*;无档位段/未知档位 → -1。 */
int ncm_cache_level_from_path(const char *path);
/* 流式写入句柄:open 建 temp;write 累积(write(2) 直写无 stdio 缓冲,§21 边下边播
* 读端即时可见);commit 落盘+刷新LRU+触发淘汰;abort 丢弃。 */
typedef struct ncm_cache_writer ncm_cache_writer_t;
@@ -88,12 +109,13 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c);
int ncm_cache_evict(const ncm_cache_t *c);
/* 缓存条目(ncm_cache_scan 输出,供「已缓存列表」展示/点播/删除)。
* 命名约定 song_<id>_<请求档位>.<实际格式>;**档位段是请求档,不是实际档**——真机存在
* song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3)。故 scan 用 ncm_audio_probe_file
* 读实际容器参数,actual_level 给**实际档**(-1=无法判定);播放/删除仍用 id+level+ext 定位。 */
* 命名约定 song_<id>_<档位>.<实际格式>(M6 起档位段=**实际档**;旧版缓存名的档位段仍是请求档,
* 如真机 song_171650_hires.mp3 = 无损请求被降级链落到 exhigh mp3,读可用、不强制迁移)。
* 故 level 只作「文件名档位键」用于定位删除/点播;actual_level 由 ncm_audio_probe_file 读实际
* 容器参数后按 ncm_cache_actual_level 判定(新命名下与文件名一致;-1=无法判定)。 */
typedef struct ncm_cache_item {
char id[24]; /* 歌曲 id(数字串) */
char level[20]; /* 文件名里的请求档位键(""=旧无档位键条目) */
char level[20]; /* 文件名档位键(新命名=实际档;旧命名=请求档;""=旧无档位键条目) */
char ext[8]; /* 实际容器扩展名(mp3/flac,去点) */
int actual_level; /* 实际档 NCM_LEVEL_*(按容器/位深/码率推断;-1=未知) */
uint64_t size; /* 文件字节数(含嵌入 tag) */
@@ -104,6 +126,7 @@ typedef struct ncm_cache_item {
/* 扫描歌曲缓存池(songs/,跳过 .tmp/.part 半成品与非 song_<数字 id> 条目),按 mtime 降序
* (最近播放/写入在前)写入 out(最多 max 条;池内更多时保留最近的 max 条)。返回条目数或负错误码。
* 同一 (id, actual_level, ext) 只保留最近访问的一条(旧请求档名 + 新实际档名并存时去重展示)。
* 每条会读文件头(tag + 音频参数探测),属磁盘 IO——**必须在 worker 线程调用**。 */
int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max);
+510
View File
@@ -0,0 +1,510 @@
/* test/test_ncm_cache.c — M6 缓存命名「实际档」宿主单测(纯 libc,不依赖 mbedtls/jsmn)。
*
* 覆盖(对应任务:命名生成/解析、降级后写入名、hires 命中已存 exhigh、旧命名兼容、LRU 淘汰):
* ① ncm_cache_actual_level:请求档 + 容器/码率/位深 + 响应生效档 → 实际档(只降不升);
* ② 命名生成:实际档键 → songs/song_<id>_<实际档>.<实际格式>(降级后不再写请求档名);
* ③ 命中语义:hires 请求命中已存 song_<id>_exhigh.mp3 / song_<id>_lossless.flac(不重下),
* 低档请求不向上命中高档实体;
* ④ 旧命名兼容:song_<id>_hires.mp3(真机存量)可命中/可扫描/可删除,不迁移;
* 历史双点名 song_<id>..mp3 命中即迁移;LRU 淘汰对两种命名一视同仁;
* ⑤ 已缓存列表去重:同曲同实际档的旧名+新名只展示最近访问的一条;不同实际档都展示;
* ⑥ ncm_cache_level_from_path 解析(含 sky_c51 / 无档位段 / 封面名)。
*
* 编译/运行($TMPDIR 纪律;Termux 的 /tmp 不可写):./build_ncm_cache_host.sh && ./test/test_ncm_cache_host
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <utime.h>
#include "ncm_common.h" /* NCM_OK / NCM_ERR_* */
#include "ncm_cache.h"
/* ---- ncm_cache.c 依赖的核心符号(宿主单测最小桩;与 core 实现同语义) ---- */
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
{
size_t n = 0;
if (sz) {
while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; }
dst[n] = 0;
}
while (src[n]) n++;
return n;
}
int ncm_store_mkdir_p(const char *path)
{
if (!path || !*path) return -1;
char tmp[512];
size_t len = strlen(path);
if (len >= sizeof tmp) return -1;
memcpy(tmp, path, len + 1);
if (tmp[len - 1] == '/') tmp[len - 1] = '\0';
for (size_t i = 1; i <= strlen(tmp); i++) {
if (tmp[i] == '/' || tmp[i] == '\0') {
char ch = tmp[i];
tmp[i] = '\0';
if (tmp[0] && mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
tmp[i] = ch;
}
}
return 0;
}
int ncm_store_read_cfg_int(const char *dir, const char *key, int def)
{ (void)dir; (void)key; return def; }
const char *ncm_api_level_str(int level)
{
switch (level) {
case NCM_LEVEL_SURROUND:
case NCM_LEVEL_SKY: return "sky";
case NCM_LEVEL_JYMASTER: return "jymaster";
case NCM_LEVEL_JYEFFECT: return "jyeffect";
case NCM_LEVEL_DOLBY: return "dolby";
case NCM_LEVEL_HIRES: return "hires";
case NCM_LEVEL_LOSSLESS: return "lossless";
case NCM_LEVEL_EXHIGH: return "exhigh";
case NCM_LEVEL_HIGHER: return "higher";
default: return "standard";
}
}
const char *ncm_api_level_key(int level)
{
if (level == NCM_LEVEL_SURROUND) return "sky_c51";
return ncm_api_level_str(level);
}
int ncm_api_level_from_key(const char *key)
{
if (!key || !key[0]) return -1;
if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND;
if (!strcmp(key, "sky")) return NCM_LEVEL_SKY;
if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER;
if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT;
if (!strcmp(key, "dolby")) return NCM_LEVEL_DOLBY;
if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES;
if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS;
if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH;
if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER;
if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD;
return -1;
}
static int g_fail;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
} while (0)
#define TMPDIR_PREFIX "ncm_cache_test"
static void fresh_dir(const char *sub)
{
char cmd[256];
snprintf(cmd, sizeof cmd, "rm -rf %s/%s; mkdir -p %s/%s",
TMPDIR_PREFIX, sub, TMPDIR_PREFIX, sub);
if (system(cmd) != 0) { /* best-effort */ }
}
/* ---- 样本:MP3 首帧(MPEG1 Layer III)+ 假音频字节 / FLAC STREAMINFO + 假帧 ---- */
static unsigned char *make_mp3(int kbps, size_t *len)
{
static unsigned char buf[512];
int idx = (kbps == 128) ? 9 : 14; /* 9=128k, 14=320k(MPEG1 L3 码率表) */
memset(buf, 0x11, sizeof buf);
buf[0] = 0xFF; buf[1] = 0xFB;
buf[2] = (unsigned char)((idx << 4) | 0x00); /* 44.1kHz(sr_idx=0) */
buf[3] = 0x00;
*len = sizeof buf;
return buf;
}
static unsigned char *make_flac(int bps, int sr, size_t *len)
{
static unsigned char buf[700];
size_t o = 0;
memset(buf, 0, sizeof buf);
memcpy(buf + o, "fLaC", 4); o += 4;
buf[o++] = 0x80; buf[o++] = 0x00; buf[o++] = 0x00; buf[o++] = 34; /* STREAMINFO, last */
unsigned char *si = buf + o;
si[0] = 0x10; si[1] = 0x00; si[2] = 0x10; si[3] = 0x00;
si[10] = (unsigned char)((sr >> 12) & 0xFF);
si[11] = (unsigned char)((sr >> 4) & 0xFF);
si[12] = (unsigned char)(((sr & 0x0F) << 4) | 0x02 | (((bps - 1) >> 4) & 0x01));
si[13] = (unsigned char)(((bps - 1) & 0x0F) << 4);
si[16] = 0xAC; si[17] = 0x44;
o += 34;
for (int i = 0; i < 512; i++) buf[o++] = (unsigned char)(0xF8 ^ i);
*len = o;
return buf;
}
static int file_put(const char *path, const void *data, size_t len)
{
FILE *f = fopen(path, "wb");
if (!f) return -1;
if (len && fwrite(data, 1, len, f) != len) { fclose(f); return -1; }
return fclose(f);
}
static int file_exists(const char *path)
{
struct stat st;
return stat(path, &st) == 0 && S_ISREG(st.st_mode);
}
static int file_exists_cstr(const char *dir, const char *name)
{
char p[512];
snprintf(p, sizeof p, "%s/songs/%s", dir, name);
return file_exists(p);
}
static void set_mtime(const char *path, long t)
{
struct timespec ts[2];
ts[0].tv_sec = t; ts[0].tv_nsec = 0;
ts[1].tv_sec = t; ts[1].tv_nsec = 0;
utimensat(AT_FDCWD, path, ts, 0);
}
/* 走生产写路径:实际档键 → ncm_cache_open/write/commit → 正式文件名。 */
static int cache_put(ncm_cache_t *c, const char *id, const char *level_key,
const char *ext, const void *data, size_t len,
char *out, size_t out_sz)
{
char key[64];
ncm_cache_writer_t *w;
snprintf(key, sizeof key, "%s_%s", id, level_key);
if (ncm_cache_open(c, key, ext, &w) != NCM_OK) return -1;
if (len && ncm_cache_write(w, (const unsigned char *)data, len) != NCM_OK) {
ncm_cache_abort(w);
return -1;
}
if (ncm_cache_commit(w) != 0) return -1;
return ncm_cache_path(c, key, ext, out, out_sz);
}
/* 直接落盘(旧命名/手工样本):songs/<name>,不走 writer。 */
static void raw_put(const ncm_cache_t *c, const char *name, const void *data, size_t len)
{
char p[NCM_CACHE_DIR_MAX + 64];
snprintf(p, sizeof p, "%s/%s", ncm_cache_songs_dir(c), name);
file_put(p, data, len);
}
/* ============================================================ */
static void test_actual_level(void)
{
ncm_audio_info_t ai;
printf("== actual level mapping (single source of truth) ==\n");
memset(&ai, 0, sizeof ai);
snprintf(ai.container, sizeof ai.container, "mp3");
ai.bitrate_kbps = 320;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_EXHIGH,
"hires + 320k mp3 -> exhigh");
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, NCM_LEVEL_EXHIGH) == NCM_LEVEL_EXHIGH,
"hires + 320k mp3 + chain eff exhigh -> exhigh");
CHECK(ncm_cache_actual_level(NCM_LEVEL_STANDARD, &ai, -1) == NCM_LEVEL_STANDARD,
"standard request never upgraded by 320k mp3");
ai.bitrate_kbps = 128;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_STANDARD,
"hires + 128k mp3 -> standard");
ai.bitrate_kbps = 0;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_EXHIGH,
"hires + mp3 br unknown -> exhigh (mp3 never above exhigh)");
CHECK(ncm_cache_actual_level(NCM_LEVEL_SURROUND, &ai, -1) == NCM_LEVEL_EXHIGH,
"surround + mp3 br unknown -> exhigh");
memset(&ai, 0, sizeof ai);
snprintf(ai.container, sizeof ai.container, "flac");
ai.bits_per_sample = 16; ai.sample_rate = 44100;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_LOSSLESS,
"hires + 16bit/44.1k flac -> lossless");
ai.bits_per_sample = 24; ai.sample_rate = 48000;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_HIRES,
"hires + 24bit/48k flac -> hires");
ai.sample_rate = 96000;
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, -1) == NCM_LEVEL_HIRES,
"hires + 24bit/96k flac -> hires (request caps)");
CHECK(ncm_cache_actual_level(NCM_LEVEL_JYMASTER, &ai, -1) == NCM_LEVEL_JYEFFECT,
"jymaster + 24bit/96k flac -> jyeffect");
ai.sample_rate = 192000;
CHECK(ncm_cache_actual_level(NCM_LEVEL_JYMASTER, &ai, -1) == NCM_LEVEL_JYMASTER,
"jymaster + 24bit/192k flac -> jymaster");
memset(&ai, 0, sizeof ai);
snprintf(ai.container, sizeof ai.container, "flac"); /* 仅知容器(响应时点) */
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, NCM_LEVEL_LOSSLESS) == NCM_LEVEL_LOSSLESS,
"hires + flac(header unknown) + chain eff lossless -> lossless");
CHECK(ncm_cache_actual_level(-1, &ai, NCM_LEVEL_HIRES) == NCM_LEVEL_HIRES,
"unknown request + chain eff hires -> hires");
CHECK(ncm_cache_actual_level(NCM_LEVEL_HIRES, NULL, -1) == NCM_LEVEL_HIRES,
"no probe + unknown chain -> request level");
}
static void test_naming_degraded_write(void)
{
ncm_cache_t c;
char dir[256], path[NCM_CACHE_DIR_MAX + 64];
size_t mlen;
unsigned char *mp3 = make_mp3(320, &mlen);
ncm_audio_info_t ai;
int actual;
printf("== naming: degraded write uses actual level (M6) ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/naming");
fresh_dir("naming");
ncm_cache_init(&c, dir);
memset(&ai, 0, sizeof ai);
snprintf(ai.container, sizeof ai.container, "mp3");
ai.bitrate_kbps = 320;
actual = ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, NCM_LEVEL_EXHIGH);
CHECK(actual == NCM_LEVEL_EXHIGH, "degraded response actual level = exhigh");
CHECK(cache_put(&c, "171650", ncm_api_level_key(actual), "mp3",
mp3, mlen, path, sizeof path) == 0, "write with actual-level key");
CHECK(strstr(path, "song_171650_exhigh.mp3") != NULL,
"file name reflects actual level (%s)", path);
CHECK(file_exists(path), "actual-named file on disk");
CHECK(!file_exists_cstr(dir, "song_171650_hires.mp3"),
"no request-level name written for degraded download");
CHECK(ncm_cache_level_from_path(path) == NCM_LEVEL_EXHIGH,
"level parsed from new file name = exhigh");
}
static void test_hit_semantics(void)
{
ncm_cache_t c;
char dir[256], path[NCM_CACHE_DIR_MAX + 64];
size_t mlen, flen;
unsigned char *mp3 = make_mp3(320, &mlen);
unsigned char *flac = make_flac(16, 44100, &flen);
printf("== hit semantics: hires request finds stored actual-level entries ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/hit");
fresh_dir("hit");
ncm_cache_init(&c, dir);
/* 已存:exhigh mp3(新命名)+ lossless flac(新命名) */
CHECK(cache_put(&c, "2001", "exhigh", "mp3", mp3, mlen, path, sizeof path) == 0,
"store exhigh mp3");
CHECK(cache_put(&c, "2002", "lossless", "flac", flac, flen, path, sizeof path) == 0,
"store lossless flac");
CHECK(ncm_cache_lookup(&c, "2001_hires", "mp3", path, sizeof path) == NCM_OK &&
strstr(path, "song_2001_exhigh.mp3") != NULL,
"hires request hits stored exhigh mp3 (no redownload)");
CHECK(ncm_cache_level_from_path(path) == NCM_LEVEL_EXHIGH,
"hit level = exhigh");
CHECK(ncm_cache_lookup(&c, "2002_hires", "flac", path, sizeof path) == NCM_OK &&
strstr(path, "song_2002_lossless.flac") != NULL,
"hires request hits stored lossless flac");
CHECK(ncm_cache_lookup(&c, "2002_exhigh", "flac", path, sizeof path) == NCM_ERR_NOENT,
"lower request never hits higher-tier entity (no upward lookup)");
CHECK(ncm_cache_lookup(&c, "2001_exhigh", "mp3", path, sizeof path) == NCM_OK,
"exact level still hits directly");
CHECK(ncm_cache_lookup(&c, "2001_standard", "mp3", path, sizeof path) == NCM_ERR_NOENT,
"standard request does not use 320k exhigh file");
CHECK(ncm_cache_lookup(&c, "9999_hires", "mp3", path, sizeof path) == NCM_ERR_NOENT,
"unknown song misses");
}
static void test_legacy_compat(void)
{
ncm_cache_t c;
char dir[256], path[NCM_CACHE_DIR_MAX + 64];
size_t mlen;
unsigned char *mp3 = make_mp3(320, &mlen);
printf("== legacy naming compatibility (read/delete/scan, no migration) ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/legacy");
fresh_dir("legacy");
ncm_cache_init(&c, dir);
/* 真机存量形态:请求档 hires + 实际 320k mp3 */
raw_put(&c, "song_171650_hires.mp3", mp3, mlen);
CHECK(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path) == NCM_OK &&
strstr(path, "song_171650_hires.mp3") != NULL,
"legacy hires.mp3 still playable at its request key");
CHECK(file_exists(path), "legacy file untouched (not renamed)");
CHECK(ncm_cache_level_from_path(path) == NCM_LEVEL_HIRES,
"path level is the legacy request level (hires)");
/* 扫描:旧文件的档位段保留(删除/点播定位用),实际档由文件头纠正 */
{
ncm_cache_item_t items[8];
int n = ncm_cache_scan(&c, items, 8);
CHECK(n == 1, "scan lists legacy entry (n=%d)", n);
CHECK(n == 1 && strcmp(items[0].id, "171650") == 0 &&
strcmp(items[0].level, "hires") == 0 && strcmp(items[0].ext, "mp3") == 0,
"legacy item id/level/ext parsed");
CHECK(n == 1 && items[0].actual_level == NCM_LEVEL_EXHIGH,
"legacy hires.mp3 displays actual level exhigh (got %d)",
n == 1 ? items[0].actual_level : -999);
if (n == 1)
CHECK(ncm_cache_remove(&c, items[0].id, items[0].level, items[0].ext) == NCM_OK,
"delete by scanned legacy key");
CHECK(!file_exists_cstr(dir, "song_171650_hires.mp3"),
"legacy file removed by list delete path");
}
/* 历史双点名 song_<id>..mp3:命中即迁移到单点名 */
raw_put(&c, "song_171651..mp3", mp3, mlen);
CHECK(ncm_cache_lookup(&c, "171651", "mp3", path, sizeof path) == NCM_OK &&
strstr(path, "song_171651.mp3") != NULL && strstr(path, "..") == NULL,
"legacy double-dot migrated on lookup (%s)", path);
CHECK(!file_exists_cstr(dir, "song_171651..mp3"), "double-dot source gone");
/* 旧无档位键 song_<id>.mp3:任何请求档都回退命中(调用方第 3 步 lookup(id)) */
CHECK(ncm_cache_lookup(&c, "171651", "mp3", path, sizeof path) == NCM_OK,
"no-level legacy key still hit");
CHECK(ncm_cache_remove(&c, "171651", "", "mp3") == NCM_OK, "remove no-level legacy key");
}
static void test_scan_dedup(void)
{
ncm_cache_t c;
char dir[256], path[NCM_CACHE_DIR_MAX + 64];
size_t mlen, flen;
unsigned char *mp3 = make_mp3(320, &mlen);
unsigned char *flac = make_flac(16, 44100, &flen);
printf("== cache list dedup: same song same actual level ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/dedup");
fresh_dir("dedup");
ncm_cache_init(&c, dir);
/* 同曲同实际档、两种命名并存(旧 song_3001_hires.mp3 新 song_3001_exhigh.mp3) */
raw_put(&c, "song_3001_hires.mp3", mp3, mlen);
raw_put(&c, "song_3001_exhigh.mp3", mp3, mlen);
{
char p[512];
snprintf(p, sizeof p, "%s/songs/song_3001_hires.mp3", dir);
set_mtime(p, 1000);
snprintf(p, sizeof p, "%s/songs/song_3001_exhigh.mp3", dir);
set_mtime(p, 2000);
}
CHECK(cache_put(&c, "3002", "lossless", "flac", flac, flen, path, sizeof path) == 0,
"store lossless entry for tier-variety case");
CHECK(cache_put(&c, "3002", "exhigh", "mp3", mp3, mlen, path, sizeof path) == 0,
"store exhigh entry for same song");
{
ncm_cache_item_t items[8];
int n = ncm_cache_scan(&c, items, 8);
int d3001 = 0, d3002 = 0;
int lv3001 = -999;
for (int i = 0; i < n; i++) {
if (strcmp(items[i].id, "3001") == 0) { d3001++; lv3001 = items[i].actual_level; }
if (strcmp(items[i].id, "3002") == 0) d3002++;
}
CHECK(d3001 == 1, "same song same actual level deduped to 1 row (got %d)", d3001);
CHECK(lv3001 == NCM_LEVEL_EXHIGH, "deduped row keeps actual level exhigh (got %d)", lv3001);
CHECK(d3002 == 2, "different actual levels both listed (got %d)", d3002);
}
}
static void test_lru_compat(void)
{
ncm_cache_t c;
char dir[256];
size_t mlen;
unsigned char *mp3 = make_mp3(320, &mlen);
char oldp[512], newp[512];
int fd;
printf("== LRU eviction compatibility (legacy + actual naming) ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/lru");
fresh_dir("lru");
ncm_cache_init(&c, dir);
ncm_cache_set_limit_gb(&c, 1); /* 1GB 最小上限 */
/* 稀疏文件:旧命名 700MB(最旧)+ 新命名 400MB(最新)→ 超限删最旧 */
snprintf(oldp, sizeof oldp, "%s/songs/song_4001_hires.mp3", dir);
snprintf(newp, sizeof newp, "%s/songs/song_4002_exhigh.mp3", dir);
fd = open(oldp, O_WRONLY | O_CREAT, 0644); if (fd >= 0) { if (ftruncate(fd, 700L << 20) != 0) {} close(fd); }
fd = open(newp, O_WRONLY | O_CREAT, 0644); if (fd >= 0) { if (ftruncate(fd, 400L << 20) != 0) {} close(fd); }
set_mtime(oldp, 1000);
set_mtime(newp, 2000);
CHECK(ncm_cache_total_bytes(&c) == (700ULL << 20) + (400ULL << 20),
"total counts both naming schemes (%llu)",
(unsigned long long)ncm_cache_total_bytes(&c));
CHECK(ncm_cache_evict(&c) == NCM_OK, "evict runs");
CHECK(!file_exists(oldp), "oldest legacy-named entry evicted");
CHECK(file_exists(newp), "newest actual-named entry kept");
CHECK(ncm_cache_total_bytes(&c) == (400ULL << 20), "total after evict = 400MB (%llu)",
(unsigned long long)ncm_cache_total_bytes(&c));
ncm_cache_set_limit_gb(&c, 10);
/* 清空兼容两种命名 */
raw_put(&c, "song_4003_hires.mp3", mp3, mlen);
raw_put(&c, "song_4004_exhigh.mp3", mp3, mlen);
CHECK(ncm_cache_clear(&c) == NCM_OK, "clear runs");
CHECK(!file_exists(newp) && !file_exists_cstr(dir, "song_4003_hires.mp3") &&
!file_exists_cstr(dir, "song_4004_exhigh.mp3"),
"clear removes both naming schemes");
}
static void test_remove_compat(void)
{
ncm_cache_t c;
char dir[256];
size_t mlen;
unsigned char *mp3 = make_mp3(320, &mlen);
printf("== remove compatibility ==\n");
snprintf(dir, sizeof dir, TMPDIR_PREFIX "/remove");
fresh_dir("remove");
ncm_cache_init(&c, dir);
raw_put(&c, "song_5001_hires.mp3", mp3, mlen); /* 旧请求档名 */
raw_put(&c, "song_5002_exhigh.mp3", mp3, mlen); /* 新实际档名 */
raw_put(&c, "song_5003.mp3", mp3, mlen); /* 旧无档位键 */
CHECK(ncm_cache_remove(&c, "5001", "hires", "mp3") == NCM_OK, "remove legacy request-level name");
CHECK(ncm_cache_remove(&c, "5002", "exhigh", "mp3") == NCM_OK, "remove actual-level name");
CHECK(ncm_cache_remove(&c, "5003", "", "mp3") == NCM_OK, "remove no-level name");
CHECK(ncm_cache_remove(&c, "5002", "exhigh", "mp3") == NCM_ERR_NOENT, "second remove = NOENT");
}
static void test_level_from_path(void)
{
printf("== level_from_path parsing ==\n");
CHECK(ncm_cache_level_from_path("/x/songs/song_171650_exhigh.mp3") == NCM_LEVEL_EXHIGH,
"song_<id>_exhigh.mp3 -> exhigh");
CHECK(ncm_cache_level_from_path("song_171650_sky_c51.flac") == NCM_LEVEL_SURROUND,
"song_<id>_sky_c51.flac -> surround");
CHECK(ncm_cache_level_from_path("song_171650.mp3") == -1, "no level segment -> -1");
CHECK(ncm_cache_level_from_path("cover_current.jpg") == -1, "cover name -> -1");
CHECK(ncm_cache_level_from_path("/tmp/pf_hires.mp3") == NCM_LEVEL_HIRES,
"bare <name>_<level>.ext parsed");
CHECK(ncm_cache_level_from_path(NULL) == -1 && ncm_cache_level_from_path("") == -1,
"NULL/empty -> -1");
}
int main(void)
{
const char *base = getenv("TMPDIR");
printf("ncm_cache naming self-test (M6)\n");
if (base && base[0] && chdir(base) != 0) printf(" warn: chdir(%s) failed\n", base);
{
char cmd[128];
snprintf(cmd, sizeof cmd, "rm -rf %s; mkdir -p %s", TMPDIR_PREFIX, TMPDIR_PREFIX);
if (system(cmd) != 0) { /* best-effort */ }
}
test_actual_level();
test_naming_degraded_write();
test_hit_semantics();
test_legacy_compat();
test_scan_dedup();
test_lru_compat();
test_remove_compat();
test_level_from_path();
printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS ==");
return g_fail ? 1 : 0;
}
+96
View File
@@ -10,6 +10,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "ncm.h"
#include "mbedtls/aes.h"
@@ -305,6 +306,100 @@ static void test_cache_tag(void) {
}
}
/* ---------- M6:缓存文件名 = 实际档(响应档) ----------
* 用户反馈「有降级显示,但是那个缓存文件名误导人」:hires 请求被降级链落到 exhigh mp3 时,
* 旧命名仍写 song_<id>_hires.mp3。本测试断言:①写盘名用实际档;②hires 请求命中已存的
* exhigh 实体(不重下);③旧命名继续可命中/可扫描/可删除、不迁移;④列表去重;⑤LRU 兼容。 */
static void test_cache_actual_naming(void) {
printf("== cache actual-level naming (M6) ==\n");
ncm_cache_t c;
const char *base = getenv("TMPDIR");
char dir[512], path[NCM_CACHE_DIR_MAX + 64];
unsigned char mp3[600];
ncm_cache_item_t items[16];
snprintf(dir, sizeof dir, "%s/ncm_cache_m6_%d", (base && base[0]) ? base : "/tmp", (int)getpid());
{
char cmd[640];
snprintf(cmd, sizeof cmd, "rm -rf %s; mkdir -p %s", dir, dir);
if (system(cmd) != 0) { /* best-effort */ }
}
ncm_cache_init(&c, dir);
memset(mp3, 0x11, sizeof mp3);
mp3[0] = 0xFF; mp3[1] = 0xFB; mp3[2] = 0xE0; mp3[3] = 0x00; /* 320kbps / 44.1kHz */
/* ① 降级响应(hires 请求 → 320k mp3)→ 实际档 exhigh → 写盘名 song_<id>_exhigh.mp3 */
{
ncm_audio_info_t ai;
char key[64];
ncm_cache_writer_t *w;
memset(&ai, 0, sizeof ai);
snprintf(ai.container, sizeof ai.container, "mp3");
ai.bitrate_kbps = 320;
int actual = ncm_cache_actual_level(NCM_LEVEL_HIRES, &ai, NCM_LEVEL_EXHIGH);
CHECK(actual == NCM_LEVEL_EXHIGH, "actual level for hires+320k mp3 = exhigh (got %d)", actual);
snprintf(key, sizeof key, "171650_%s", ncm_api_level_key(actual));
CHECKRC(ncm_cache_open(&c, key, "mp3", &w), "open with actual-level key");
CHECKRC(ncm_cache_write(w, mp3, sizeof mp3), "write degraded body");
CHECKRC(ncm_cache_commit(w), "commit actual-level entry");
CHECKRC(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path),
"hires request hits stored exhigh entry");
CHECK(strstr(path, "song_171650_exhigh.mp3") != NULL, "hit path = actual-level name (%s)", path);
CHECK(ncm_cache_level_from_path(path) == NCM_LEVEL_EXHIGH, "level parsed from path = exhigh");
}
/* ② 旧命名存量(真机 song_<id>_hires.mp3):按原键命中,不迁移;低档请求不向上命中 */
{
char p[NCM_CACHE_DIR_MAX + 64];
CHECKRC(ncm_cache_path(&c, "171651_hires", "mp3", p, sizeof p), "legacy path");
FILE *f = fopen(p, "wb");
if (f) { fwrite(mp3, 1, sizeof mp3, f); fclose(f); }
CHECKRC(ncm_cache_lookup(&c, "171651_hires", "mp3", path, sizeof path),
"legacy hires.mp3 hit at its own key");
CHECK(strstr(path, "song_171651_hires.mp3") != NULL, "legacy name not migrated (%s)", path);
CHECK(ncm_cache_lookup(&c, "171651_exhigh", "mp3", path, sizeof path) == NCM_ERR_NOENT,
"lower request does not hit higher-named legacy entry (down-only ladder)");
}
/* ③ 列表:旧名条目的档位段保留、实际档由文件头纠正;同曲同实际档的旧名+新名去重 */
{
char p[NCM_CACHE_DIR_MAX + 64];
CHECKRC(ncm_cache_path(&c, "171652_hires", "mp3", p, sizeof p), "legacy dup path");
FILE *f = fopen(p, "wb");
if (f) { fwrite(mp3, 1, sizeof mp3, f); fclose(f); }
CHECKRC(ncm_cache_path(&c, "171652_exhigh", "mp3", p, sizeof p), "new dup path");
f = fopen(p, "wb");
if (f) { fwrite(mp3, 1, sizeof mp3, f); fclose(f); }
int n = ncm_cache_scan(&c, items, 16);
int seen_old = 0, seen_dup = 0, lv_old = -999;
for (int i = 0; i < n; i++) {
if (strcmp(items[i].id, "171651") == 0) {
seen_old = 1; lv_old = items[i].actual_level;
CHECK(strcmp(items[i].level, "hires") == 0, "legacy item keeps file-name level (hires)");
}
if (strcmp(items[i].id, "171652") == 0) seen_dup++;
}
CHECK(seen_old && lv_old == NCM_LEVEL_EXHIGH,
"legacy hires.mp3 displays actual exhigh (got %d)", lv_old);
CHECK(seen_dup == 1, "same song same actual level deduped to 1 row (got %d)", seen_dup);
}
/* ④ 删除/淘汰对两种命名一视同仁 */
CHECKRC(ncm_cache_remove(&c, "171651", "hires", "mp3"), "remove legacy-named entry");
CHECK(ncm_cache_lookup(&c, "171651_hires", "mp3", path, sizeof path) == NCM_ERR_NOENT,
"legacy entry gone after remove");
c.limit_bytes = 1;
CHECKRC(ncm_cache_evict(&c), "evict mixed naming pool");
CHECK(ncm_cache_scan(&c, items, 16) == 0, "all entries evicted regardless of naming");
ncm_cache_set_limit_gb(&c, 10);
{
char cmd[640];
snprintf(cmd, sizeof cmd, "rm -rf %s", dir);
if (system(cmd) != 0) { /* best-effort */ }
}
}
static void test_qr(void) {
printf("== qrcodegen ==\n");
unsigned char mods[177 * 177];
@@ -679,6 +774,7 @@ int main(int argc, char **argv) {
test_store_cookie();
test_cache();
test_cache_tag();
test_cache_actual_naming();
test_qr();
test_cookie_merge();
test_eapi_musicu_hdr();