feat(ncm §20): 音质体系全量——八档UI/VIP检测缓存/逐级降级下首恢复/fee门禁/默认极高HQ

- core: 9档枚举(顺序=降级链, DOLBY映射保留不进UI/链) + level_key(sky/sky_c51)
  + level_gate(CVIP/SVIP门槛) + song_url surround档附加immerseType=c51
  + song_url_auto全档表降级(out_level回填/响应level静默降档采信/try_fn可注入)
  + vip_info(weapi v2优先v1兜底) + parse_vip_info离线可测 + NCM_ERR_VIP(-15)
- app/logic/account.c(新): account.json会员态缓存(uid/nick/vip/双expire/checked_at)
  刷新=登录后/超1天/到期后各一次不轮询; account_vip本地到期降档视图
- player_manager: 用户档按会员钳顶后逐级降级(不硬拒,缓存键仍用户档),
  fee∈{1,4}且非会员→NCM_ERR_VIP零请求(缓存命中优先), eff_level回填
- UI: 设置页八档(规格+门槛+未解锁标注,允许选定), 播放页时间行中央
  生效档/已降级至xx小字, VIP专享文案; 默认档Hi-Res→极高HQ(存量设置保留)
- 验证(0真实请求): degrade_sim 90例矩阵全PASS; player_flow_sim P1-P8全PASS;
  test_ncm_core vip解析9例宿主+qemu全PASS; 窗口/分页/缓存/封面/audiod sim全PASS;
  mipsel -Wall -Wextra 0 warning; 6页离屏rc=0。未部署真机(留主会话)
This commit is contained in:
MeowOS committed 2026-09-01 15:55:42 +08:00
1 parent ab4dfb4f9c
commit bdd9e898f4
23 files changed
+1026 -70

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ typedef struct ncm_ctx {
int verify_ca; /* 1=校验CA(默认) */
int timeout_ms; /* HTTP 超时 */
char ca_path[NCM_CTX_CA_PATH_MAX]; /* 可选 CA bundle */
int default_level; /* NCM_LEVEL_* 默认音质(Hi-res) */
int default_level; /* NCM_LEVEL_* 默认音质(§20:极高 HQ=EXHIGH) */
} ncm_ctx_t;
/* 默认缓存目录(<data_dir>/../cache,即 MeowData/dev.player.ncm/cache)。 */
+169 -13
View File
@@ -11,11 +11,36 @@
const char *ncm_api_level_str(int level) {
switch (level) {
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";
case NCM_LEVEL_SURROUND: /* "sky" + immerseType=c51(song_url 内处理) */
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";
}
}
/* 缓存键唯一后缀(NCM_CACHE_DESIGN §2.5 键=歌曲ID+档位):SKY 与 SURROUND 的 API
* level 串同为 "sky",键必须可区分,否则两档互相串缓存。 */
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_gate(int level) {
switch (level) {
case NCM_LEVEL_LOSSLESS:
case NCM_LEVEL_HIRES: return NCM_VIP_CVIP;
case NCM_LEVEL_JYEFFECT:
case NCM_LEVEL_JYMASTER:
case NCM_LEVEL_SKY:
case NCM_LEVEL_SURROUND:
case NCM_LEVEL_DOLBY: return NCM_VIP_SVIP;
default: return NCM_VIP_NONE;
}
}
@@ -237,9 +262,12 @@ int ncm_api_song_url(const char *device_id, const char *cookie, const char *id,
if (!out) return NCM_ERR_ARG;
memset(out, 0, sizeof *out);
char json[256];
/* §20:level=="sky" 且为沉浸环绕声(SURROUND)档时附加 immerseType=c51
* (BIZ_NOTES §2.2:sky 是唯一带附加参数的档;c51=5.1 声道配置)。 */
snprintf(json, sizeof json,
"{\"ids\":\"[%s]\",\"level\":\"%s\",\"encodeType\":\"flac\"}",
id, ncm_api_level_str(level));
"{\"ids\":\"[%s]\",\"level\":\"%s\",\"encodeType\":\"flac\"%s}",
id, ncm_api_level_str(level),
level == NCM_LEVEL_SURROUND ? ",\"immerseType\":\"c51\"" : "");
api_json_t c; memset(&c, 0, sizeof c);
int rc = api_post(device_id, cookie, "/api/song/enhance/player/url/v1", json, NCM_CRYPT_EAPI,
@@ -257,15 +285,35 @@ int ncm_api_song_url(const char *device_id, const char *cookie, const char *id,
return NCM_OK;
}
int ncm_api_song_url_auto(const char *device_id, const char *cookie, const char *id,
int desired, ncm_play_url_t *out) {
/* 响应 level 串 → 档位(比对服务端实际下发档用)。sky 族有歧义(sky 可指 SKY/SURROUND),
* 无法区分时返回 -1 让调用方保守处理。dolby 不在降级链,映射保留。 */
static int level_from_str(const char *s) {
if (!s || !s[0]) return -1;
if (ncm_streq(s, "standard")) return NCM_LEVEL_STANDARD;
if (ncm_streq(s, "higher")) return NCM_LEVEL_HIGHER;
if (ncm_streq(s, "exhigh")) return NCM_LEVEL_EXHIGH;
if (ncm_streq(s, "lossless")) return NCM_LEVEL_LOSSLESS;
if (ncm_streq(s, "hires")) return NCM_LEVEL_HIRES;
if (ncm_streq(s, "jyeffect")) return NCM_LEVEL_JYEFFECT;
if (ncm_streq(s, "jymaster")) return NCM_LEVEL_JYMASTER;
return -1; /* sky/dolby/未知:不采信 */
}
/* 降级链(§20 全档表):从 desired 逐档向下;DOLBY(=16) 不在链内(不进 UI/不降级经过)。
* 单次请求失败(网络/鉴权)与「该档不可用」(url null/未知格式)都继续降级。 */
int ncm_api_song_url_auto_f(const char *device_id, const char *cookie, const char *id,
int desired, ncm_play_url_t *out, int *out_level,
ncm_song_url_try_fn try_fn) {
if (!try_fn) try_fn = ncm_api_song_url;
if (out_level) *out_level = -1;
int start = desired;
if (start < NCM_LEVEL_STANDARD) start = NCM_LEVEL_HIRES;
if (start > NCM_LEVEL_HIRES) start = NCM_LEVEL_HIRES;
/* 从 desired 往下降级:hires->lossless->exhigh->higher->standard */
if (start < NCM_LEVEL_STANDARD) start = NCM_LEVEL_STANDARD;
if (start > NCM_LEVEL_UI_MAX) start = NCM_LEVEL_UI_MAX;
/* 从 desired 往下降级:surround(sky+c51)->sky->jymaster->jyeffect->hires->lossless
* ->exhigh->higher->standard */
for (int lv = start; lv >= NCM_LEVEL_STANDARD; lv--) {
ncm_play_url_t p;
int rc = ncm_api_song_url(device_id, cookie, id, lv, &p);
int rc = try_fn(device_id, cookie, id, lv, &p);
if (rc != NCM_OK) continue;
/* §19:接受 mp3 与 flac(dr_flac 落地后无损/Hi-Res 真实生效)。
* type 权威;type 缺失(旧端点兼容)时看 url 扩展名,默认当 mp3 收。 */
@@ -277,6 +325,13 @@ int ncm_api_song_url_auto(const char *device_id, const char *cookie, const char
}
if (p.url[0] && ok) {
*out = p;
if (out_level) {
/* 服务端可能在 url 非空时仍静默降档(响应 data[0].level < 请求档)——
* 无歧义串(standard..jymaster)且低于请求档时采信响应档;
* sky 族无法区分 SKY/SURROUND,保守取请求档。 */
int rl = level_from_str(p.level);
*out_level = (rl >= 0 && rl < lv) ? rl : lv;
}
return NCM_OK;
}
/* url 为 null 或未知格式:继续降级 */
@@ -284,6 +339,107 @@ int ncm_api_song_url_auto(const char *device_id, const char *cookie, const char
return NCM_ERR_NOTFOUND;
}
int ncm_api_song_url_auto(const char *device_id, const char *cookie, const char *id,
int desired, ncm_play_url_t *out, int *out_level) {
return ncm_api_song_url_auto_f(device_id, cookie, id, desired, out, out_level, NULL);
}
/* ---- 会员信息(vip/info,BIZ_NOTES §1)---- */
/* 子对象判定辅助:obj 下 vipCode>0 / isSign==true / expireTime>now 任一成立 → 1。 */
static int sub_active(const ncm_json_t *j, int root, const char *key,
int64_t now_ms, int64_t *out_expire) {
int t = ncm_json_obj_get(j, root, key);
if (t < 0 || ncm_json_type(j, t) != JSMN_OBJECT) return 0;
int rc = 0;
int64_t exp = ncm_json_get_i64_at(j, ncm_json_obj_get(j, t, "expireTime"), &rc);
if (rc == NCM_OK && exp > *out_expire) *out_expire = exp; /* 原值照记(可能已过期) */
long code = ncm_json_get_int_at(j, ncm_json_obj_get(j, t, "vipCode"), &rc);
if (rc == NCM_OK && code > 0) return 1;
int st = ncm_json_obj_get(j, t, "isSign");
char s[8];
if (st >= 0 && ncm_json_get_str_at(j, st, s, sizeof s) && strcmp(s, "true") == 0) return 1;
if (exp > now_ms) return 1;
return 0;
}
int ncm_api_parse_vip_info(const char *json, size_t len, int64_t now_ms, ncm_vip_info_t *out) {
if (!json || len == 0 || !out) return NCM_ERR_ARG;
memset(out, 0, sizeof *out);
jsmn_parser p; jsmn_init(&p);
int n = jsmn_parse(&p, json, len, NULL, 0);
if (n <= 0) return NCM_ERR_JSON;
jsmntok_t *toks = (jsmntok_t *)malloc(sizeof(jsmntok_t) * (size_t)n);
int *parent = (int *)malloc(sizeof(int) * (size_t)n);
if (!toks || !parent) { free(toks); free(parent); return NCM_ERR_OOM; }
ncm_json_t j;
int rc = ncm_json_parse(&j, json, len, toks, n, parent);
if (rc < 0) { free(toks); free(parent); return NCM_ERR_JSON; }
/* code 存在且 != 200 → 失败(v2 无权限/会话失效时回 4xx/非 200 code) */
long code = 200;
if (ncm_json_path_int(&j, "code", &code) == NCM_OK && code != 200) {
free(toks); free(parent);
return (code == 301 || code == -460) ? NCM_ERR_AUTH : NCM_ERR_HTTP;
}
/* root = data 对象优先,否则 body 本身(v2 挂 data,v1 字段直接在根部) */
int root = ncm_json_path(&j, "data");
if (root < 0 || ncm_json_type(&j, root) != JSMN_OBJECT) root = 0;
/* 黑胶(associator)→ SVIP;音乐包/畅听(musicPackage)→ CVIP(BIZ_NOTES §1.2) */
int svip = sub_active(&j, root, "associator", now_ms, &out->svip_expire_ms);
int cvip = sub_active(&j, root, "musicPackage", now_ms, &out->cvip_expire_ms);
/* 顶层字段(红胶等级/vipCode 通用/布尔位),任一成立至少 CVIP */
static const char *const int_keys[] = {
"redVipLevel", "redplusLevel", "musicVipLevel", "vipCode",
};
for (size_t i = 0; i < sizeof int_keys / sizeof int_keys[0]; i++) {
int rc2 = 0;
long v = ncm_json_get_int_at(&j, ncm_json_obj_get(&j, root, int_keys[i]), &rc2);
if (rc2 == NCM_OK && v > 0) { cvip = 1; break; }
}
static const char *const bool_keys[] = { "vipStatus", "isVip" };
for (size_t i = 0; i < sizeof bool_keys / sizeof bool_keys[0]; i++) {
int t = ncm_json_obj_get(&j, root, bool_keys[i]);
char s[8];
if (t >= 0 && ncm_json_get_str_at(&j, t, s, sizeof s) && strcmp(s, "true") == 0) {
cvip = 1; break;
}
}
out->vip = svip ? NCM_VIP_SVIP : (cvip ? NCM_VIP_CVIP : NCM_VIP_NONE);
free(toks); free(parent);
return NCM_OK;
}
int ncm_api_vip_info(const char *device_id, const char *cookie, const char *uid,
ncm_vip_info_t *out) {
if (!out) return NCM_ERR_ARG;
memset(out, 0, sizeof *out);
char json[128];
snprintf(json, sizeof json, "{\"userId\":\"%s\"}", (uid && uid[0]) ? uid : "");
/* v2 优先(/client/vip/info),失败/解析失败再 v1 兜底(/front/vip/info)——
* 每个触发点最多 2 次请求,不轮询(风控)。 */
static const char *const paths[] = {
"/api/music-vip-membership/client/vip/info",
"/api/music-vip-membership/front/vip/info",
};
int rc = NCM_ERR_HTTP;
for (size_t i = 0; i < sizeof paths / sizeof paths[0]; i++) {
api_json_t c; memset(&c, 0, sizeof c);
rc = api_post(device_id, cookie, paths[i], json, NCM_CRYPT_WEAPI, NCM_RESP_MAX, &c);
if (rc != NCM_OK) continue;
rc = ncm_api_parse_vip_info(c.resp.body, c.resp.body_len, ncm_now_ms(), out);
api_free(&c);
if (rc == NCM_OK) return NCM_OK;
}
return rc;
}
int ncm_api_lyric(const char *device_id, const char *cookie, const char *id,
char *lrc, size_t lrc_sz, char *tlrc, size_t tlrc_sz) {
char json[256];
+49 -4
View File
@@ -16,15 +16,38 @@ extern "C" {
#endif
/* 音质档位(song_url_v1 level)。可解码 mp3 + flac(§19 dr_flac 落地):
* standard/higher/exhigh 通常 mp3;lossless/hires 通常 flac(取决于曲库与会员)。 */
* standard/higher/exhigh 通常 mp3;lossless 及以上通常 flac(取决于曲库与会员)。
* §20 八档音质体系(用户定案 NCM_CACHE_DESIGN §3,UI 权威):
* 枚举值顺序 = 降级顺序(高→低)。UI 八档 = STANDARD/EXHIGH/LOSSLESS/HIRES/
* JYEFFECT/SKY/JYMASTER/SURROUND;HIGHER(较高 192k)仅作降级链中间档,不进 UI;
* DOLBY(杜比全景声)映射保留但**不进 UI 清单、不进降级链**(用户定案)。
* SKY=臻音全景声 Audio Vivid、SURROUND=沉浸环绕声(同 level 串 "sky",SURROUND
* 额外带 immerseType=c51,BIZ_NOTES §2.2)——二者 API 串相同,缓存键须用
* ncm_api_level_key(唯一后缀 sky / sky_c51)区分。 */
enum {
NCM_LEVEL_STANDARD = 0,
NCM_LEVEL_HIGHER,
NCM_LEVEL_EXHIGH,
NCM_LEVEL_LOSSLESS,
NCM_LEVEL_HIRES,
NCM_LEVEL_JYEFFECT, /* 高清臻音 Spatial(96k/24bit),SVIP */
NCM_LEVEL_JYMASTER, /* 超清母带 Master(192k/24bit),SVIP */
NCM_LEVEL_SKY, /* 臻音全景声 Audio Vivid,SVIP(level 串 "sky") */
NCM_LEVEL_SURROUND, /* 沉浸环绕声 Surround 5.1,SVIP("sky" + immerseType=c51) */
NCM_LEVEL_UI_MAX = NCM_LEVEL_SURROUND,
NCM_LEVEL_DOLBY = 16, /* 杜比全景声:映射保留,不进 UI/降级链 */
};
const char *ncm_api_level_str(int level);
const char *ncm_api_level_str(int level); /* API level 串(SKY/SURROUND 同为 "sky") */
const char *ncm_api_level_key(int level); /* 缓存键唯一后缀(SKY="sky"/SURROUND="sky_c51") */
/* 会员等级(vip/info 判定,BIZ_NOTES §1):SVIP(黑胶)权限 ⊇ CVIP(畅听/音乐包)。 */
enum {
NCM_VIP_NONE = 0,
NCM_VIP_CVIP = 1,
NCM_VIP_SVIP = 2,
};
/* 档位所需会员门槛:standard/higher/exhigh=NONE;lossless/hires=CVIP;其余=SVIP。 */
int ncm_api_level_gate(int level);
typedef struct ncm_playlist {
char id[32];
@@ -77,9 +100,31 @@ int ncm_api_song_url(const char *device_id, const char *cookie, const char *id,
int level, ncm_play_url_t *out);
/* 播放地址自动降级:从 desired 档开始,遇 url 为 null 或未知格式则逐步降档。
* §19 起接受 mp3 与 flac(type 权威;type 缺失看 url 扩展名,默认 mp3)。desired 默认 hires。 */
* §19 起接受 mp3 与 flac(type 权威;type 缺失看 url 扩展名,默认 mp3)。
* §20:降级链覆盖全档表(desired 钳到 SURROUND;DOLBY 不在链内);*out_level 回填
* 实际命中档(NULL 不填;下一首调用方重新从用户档起步 = 「下首恢复」)。 */
int ncm_api_song_url_auto(const char *device_id, const char *cookie, const char *id,
int desired, ncm_play_url_t *out);
int desired, ncm_play_url_t *out, int *out_level);
/* 可注入逐档取址的降级链(离线 sim 用):try_fn 为 NULL 时等价 ncm_api_song_url。 */
typedef int (*ncm_song_url_try_fn)(const char *device_id, const char *cookie,
const char *id, int level, ncm_play_url_t *out);
int ncm_api_song_url_auto_f(const char *device_id, const char *cookie, const char *id,
int desired, ncm_play_url_t *out, int *out_level,
ncm_song_url_try_fn try_fn);
/* 会员信息(BIZ_NOTES §1):vip/info v2 优先、v1 兜底(均 weapi)。
* 风控:只在「登录后 / 缓存超 1 天 / 到期后」由 App 层触发一次,不轮询。 */
typedef struct ncm_vip_info {
int vip; /* NCM_VIP_* */
int64_t cvip_expire_ms; /* 音乐包/畅听到期(musicPackage.expireTime;0=无) */
int64_t svip_expire_ms; /* 黑胶到期(associator.expireTime;0=无) */
} ncm_vip_info_t;
int ncm_api_vip_info(const char *device_id, const char *cookie, const char *uid,
ncm_vip_info_t *out);
/* 离线解析 vip/info 响应体(v1/v2 同构:root = data 或 body 本身)。供单元测试/sim 用。 */
int ncm_api_parse_vip_info(const char *json, size_t len, int64_t now_ms, ncm_vip_info_t *out);
/* 歌词(eapi /song/lyric):lrc 原文 + 翻译。 */
int ncm_api_lyric(const char *device_id, const char *cookie, const char *id,
+1
View File
@@ -30,6 +30,7 @@ enum {
NCM_ERR_NOENT = -12, /* 文件不存在 */
NCM_ERR_AUTH = -13, /* 未登录/鉴权失败 */
NCM_ERR_LIMIT = -14, /* 超出上限级 */
NCM_ERR_VIP = -15, /* VIP 专享(fee 门槛命中,未发 song_url 请求,§20) */
};
/* ---------- 常用尺寸 ---------- */