diff --git a/.gitignore b/.gitignore index 6270093..725c738 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,4 @@ patch_v2/ncm_app/audiod_chain_sim patch_v2/ncm_app/window_sim patch_v2/ncm_app/cover_chain_sim patch_v2/ncm_app/player_flow_sim +patch_v2/ncm_app/degrade_sim diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 1ab5b3a..7c2ab78 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -1741,3 +1741,138 @@ f4fb97416868b9a61b6b54f8958bc6f9 build_ncm_host.sh `DR_FLAC_NO_CRC`(省 CRC 校验,~5-10% CPU)。 - 测试样本(out/test-vectors/)与宿主/ffmpeg 工具链(out/tools/)在 out/ 下不入库; 样本可用 python `soundfile` 一键重造(sweep 参数见 §19.7)。 + +## 20. 音质体系全量落地:八档 UI + VIP 检测/解锁 + 逐级降级下首恢复 + fee 门禁 + 默认极高 HQ(2026-09-01) + +> 范围:仅 `patch_v2/ncm_app/`。按用户定案 NCM_CACHE_DESIGN §3(八档 UI 权威)+ §2.1 +> (VIP 缓存策略)+ CNMPLAYER_BIZ_NOTES §1/§2/§3(端点/9 档映射/fee 口径)实现。 +> 风控纪律:本批真实 NCM 请求 **0 次**(vip 解析/降级链全部离线样本 + stub 矩阵)。 +> **本批未部署真机**(部署留主会话统一做)。 + +### 20.1 档位模型(core/ncm_api.h) + +- 枚举扩到全档表,**枚举值顺序 = 降级顺序**(高→低): + `SURROUND(8) > SKY(7) > JYMASTER(6) > JYEFFECT(5) > HIRES(4) > LOSSLESS(3) > EXHIGH(2) + > HIGHER(1) > STANDARD(0)`;`DOLBY=16` 映射保留但**不进 UI 清单、不进降级链**; + `HIGHER`(较高 192k)仅降级链中间档,不进 UI。 +- UI 八档 ↔ level 串 ↔ 门槛(BIZ_NOTES §2.1 对齐 CNMPlayer 串;中文名以设计文档 §3 为准): + + | UI 档 | level 串 | 规格 | 门槛 | + |---|---|---|---| + | 标准 | standard | 128kbps | 免费 | + | 极高 HQ | exhigh | ≤320kbps | 免费(**默认档**) | + | 无损 SQ | lossless | 48kHz/16bit | CVIP | + | Hi-Res | hires | 192kHz/24bit | CVIP | + | 高清臻音 Spatial | jyeffect | 96kHz/24bit | SVIP | + | 臻音全景声 Audio Vivid | sky | Audio Vivid | SVIP | + | 超清母带 Master | jymaster | 192kHz/24bit | SVIP | + | 沉浸环绕声 Surround | sky + `immerseType=c51` | 5.1 声道 | SVIP | + +- ⚠️ sky 歧义处理(BIZ_NOTES §2.1 命名歧义的落地决策):CNMPlayer 无独立 Audio Vivid 档, + `sky` 是唯一串且其附加参数 `immerseType=c51` 恰为 5.1 声道 → **臻音全景声=裸 sky、 + 沉浸环绕声=sky+c51**(song_url 内按档附加)。两档 API 串相同 → 缓存键改用新 + `ncm_api_level_key()`(sky / sky_c51 唯一后缀),杜绝两档互串缓存。 +- `ncm_api_level_gate()`:standard/higher/exhigh=NONE,lossless/hires=CVIP,其余=SVIP。 + +### 20.2 VIP 检测与落盘(core vip/info + app/logic/account.c) + +- `ncm_api_vip_info`(weapi):先 `/api/music-vip-membership/client/vip/info`(v2,{userId}), + 失败/解析失败再 `/front/vip/info`(v1)兜底——**每个触发条件最多 2 次请求,不轮询**。 +- 判定(`ncm_api_parse_vip_info`,离线可测):root=data 或 body 本身; + `associator`(黑胶)vipCode>0 / isSign / expireTime>now → **SVIP**; + `musicPackage`(畅听/音乐包)同三判据 + 顶层 redVipLevel/redplusLevel/musicVipLevel/ + vipCode>0 或 vipStatus/isVip=true → **CVIP**;SVIP ⊇ CVIP。到期时间双字段原样记录。 +- 落盘 `MeowData/dev.player.ncm/data/account.json`:{uid,nickname,vip,cvip_expire_ms, + svip_expire_ms,checked_at}(原子写;启动 account_load 恢复先用缓存渲染)。 +- 刷新时机(account_vip_maybe_refresh,调用方 worker 线程上下文,UI 零网络): + ①登录成功后(login worker,force)②checked_at 超 1 天 ③会员到期时间已过 + (可能自动续费)——**每条件每次启动只查一次**(单飞标志,失败也置位:防风控, + 等下次启动/下个条件)。home_worker 歌单拉取成功后触发 ②③ 判定。 +- `account_vip()` 为纯本地视图:到期即按非会员对待;登出清 vip 态 + 删 account.json。 + +### 20.3 逐级降级 + 下首恢复 + 生效档显示 + +- `ncm_api_song_url_auto` 扩全档表:从 desired 逐档向下(surround→…→standard), + url 空/未知 type 继续降;**out_level 回填实际命中档**;响应 `data[0].level` 无歧义串 + (standard..jymaster)且低于请求档时采信响应档(服务端静默降档如实显示),sky 族 + 歧义保守取请求档。新增 `ncm_api_song_url_auto_f`(try_fn 可注入)供离线 sim。 +- 播放路径(player_manager song_local_file):用户档先按 `account_level_cap()` 钳顶 + (NONE→EXHIGH / CVIP→HIRES / SVIP→SURROUND)再降级——**超权限档允许选定不硬拒, + 但免去注定空 url 的高档请求**(风控)。缓存键仍=用户设定档(降级产物归用户档键, + 「同设定永不重下」,§19 语义不变)。下一首从用户档重新起步(链内无记忆)。 +- 生效档 `g_play.eff_level`(mu 保护):下载时由 auto 回填;缓存命中按 ext 推断 + (mp3 实体+用户档>极高 → 必为降级产物标 EXHIGH;flac 实体按用户档——档内降级 + 如 hires→lossless 会高估,已知取舍,真实档位无损音质感官一致)。play/next 重置 -1。 +- 播放页动态带时间行中央小字:生效档=用户档显示档名,不同则红字「已降级至 xx」 + (g_last_eff 沿触发刷带)。 + +### 20.4 fee 权限门禁(BIZ_NOTES §3 社区口径) + +- `song_local_file` 在**缓存查找之后、song_url 之前**:fee∈{1(VIP 才能听),4(付费专)} + 且 account_vip()) -> vip=N`)+ account.json 落盘 + ②SVIP 档(jyeffect/sky/jymaster/sky_c51)按账号实测可用性逐级降 ③播放页「已降级至 xx」 + 小字 ④fee=1 非会员曲点播放直接「VIP 专享」且无 song_url 请求 ⑤新装默认档=极高 HQ。 +- **sky 两档语义自研分配**(臻音全景声=裸 sky / 沉浸环绕声=sky+c51):BIZ_NOTES §2.1 命名 + 歧义无代码证据可消,真机若实测相反(如裸 sky 才是 5.1),只需对调 settings.c UI 表两项 + 的档位常量,零结构改动。Audio Vivid 若官方另有独立端点/参数则后续再补。 +- 缓存命中的 flac 实体 eff 档推断可能高估(档内降级不可还原);VIP 档真实可用性以服务端 + url 是否为空为准(门槛表只是省请求优化,服务端裁决权威)。 +- vipType(login_status profile.vipType 10/11 口径)未用——BIZ_NOTES §1.2 标注非代码证据, + 以 vip/info 双端点字段判定为准;真机若 vip/info 全挂可再加 profile.vipType 兜底。 +- meow_sheet 无单项置灰(SDK 不在范围):未解锁档以「·未解锁」标注代替置灰。 diff --git a/patch_v2/ncm_app/app/logic/account.c b/patch_v2/ncm_app/app/logic/account.c new file mode 100644 index 0000000..52d6077 --- /dev/null +++ b/patch_v2/ncm_app/app/logic/account.c @@ -0,0 +1,129 @@ +/* logic/account.c — 账号会员态缓存与刷新策略(§20,NCM_CACHE_DESIGN §2.1 定案)。 + * + * 落盘:/account.json = {uid, nickname, vip, cvip_expire_ms, svip_expire_ms, + * checked_at}。开 App 先用缓存渲染(account_load 进 g_st);后台重查时机(风控:不轮询): + * ① 登录成功后(login worker,force=1); + * ② checked_at 距今 > 1 天; + * ③ 会员到期时间已过(可能自动续费,再查一次)。 + * 每条件每次启动只查一次(单飞标志);查询在调用方 worker 线程上下文执行(UI 零网络)。 + * account_vip() 是纯本地视图:到期时间已过即按降档后的有效态返回(不联网)。 + */ +#include "../ncm_app_internal.h" +#include +#include + +#define ACCOUNT_FILE "account.json" +#define VIP_TTL_SEC (24 * 3600) /* 超 1 天重查 */ + +/* 单飞标志(每次启动每条件最多一次请求) */ +static int s_refreshed_login; /* 登录后已查 */ +static int s_refreshed_stale; /* 超 1 天已查 */ +static int s_refreshed_expired; /* 到期后已查 */ + +static void account_path(char *out, size_t sz) +{ + snprintf(out, sz, "%s/%s", g_st.data_dir, ACCOUNT_FILE); +} + +void account_load(void) +{ + g_st.vip = NCM_VIP_NONE; + g_st.vip_expire_ms = 0; + g_st.vip_checked_at = 0; + + char path[600], buf[512]; + account_path(path, sizeof path); + int len = ncm_store_read_file(path, buf, sizeof buf); + if (len <= 0) return; + + /* 小 JSON 直接 jsmn 解析(栈上 token 数组,≤48 token 足够) */ + jsmntok_t toks[48]; + int parent[48]; + ncm_json_t j; + if (ncm_json_parse(&j, buf, (size_t)len, toks, 48, parent) < 0) return; + + long vip = 0, checked = 0; + ncm_json_path_int(&j, "vip", &vip); + ncm_json_path_int(&j, "checked_at", &checked); + int rc = 0; + int64_t cvip_exp = ncm_json_get_i64_at(&j, ncm_json_path(&j, "cvip_expire_ms"), &rc); + if (rc != NCM_OK) cvip_exp = 0; + int64_t svip_exp = ncm_json_get_i64_at(&j, ncm_json_path(&j, "svip_expire_ms"), &rc); + if (rc != NCM_OK) svip_exp = 0; + + if (vip < NCM_VIP_NONE || vip > NCM_VIP_SVIP) vip = NCM_VIP_NONE; + g_st.vip = (int)vip; + g_st.vip_checked_at = checked; + /* 有效到期时间 = 当前档对应的到期字段 */ + g_st.vip_expire_ms = (vip == NCM_VIP_SVIP) ? svip_exp + : (vip == NCM_VIP_CVIP) ? cvip_exp : 0; + meow_log(MEOW_LOG_INFO, "account: loaded vip=%d expire=%lld checked_at=%ld", + g_st.vip, (long long)g_st.vip_expire_ms, (long)g_st.vip_checked_at); +} + +static void account_save(int vip, int64_t cvip_exp, int64_t svip_exp, int64_t checked_at) +{ + char path[600], buf[512]; + account_path(path, sizeof path); + snprintf(buf, sizeof buf, + "{\"uid\":\"%s\",\"nickname\":\"%s\",\"vip\":%d," + "\"cvip_expire_ms\":%lld,\"svip_expire_ms\":%lld,\"checked_at\":%lld}", + g_st.uid, g_st.nickname, vip, + (long long)cvip_exp, (long long)svip_exp, (long long)checked_at); + if (ncm_store_write_file(path, buf, strlen(buf)) != NCM_OK) + meow_log(MEOW_LOG_WARN, "account: save failed"); +} + +/* 当前有效会员档(本地视图,不联网):到期时间已过则降档。 */ +int account_vip(void) +{ + int vip = g_st.vip; + if (vip > NCM_VIP_NONE && g_st.vip_expire_ms > 0 && + g_st.vip_expire_ms <= ncm_now_ms()) + vip = NCM_VIP_NONE; /* 已到期:按非会员对待(后台会再查一次确认续费) */ + return vip; +} + +int account_level_cap(void) +{ + switch (account_vip()) { + case NCM_VIP_SVIP: return NCM_LEVEL_SURROUND; + case NCM_VIP_CVIP: return NCM_LEVEL_HIRES; + default: return NCM_LEVEL_EXHIGH; + } +} + +void account_vip_maybe_refresh(int force) +{ + if (!g_st.logged_in || !g_st.ctx.cookie[0]) return; + + int64_t now_s = ncm_now_ms() / 1000; + int expired = (g_st.vip > NCM_VIP_NONE && g_st.vip_expire_ms > 0 && + g_st.vip_expire_ms <= ncm_now_ms()); + const char *why = NULL; + if (force && !s_refreshed_login) why = "login"; + else if ((g_st.vip_checked_at == 0 || now_s - g_st.vip_checked_at > VIP_TTL_SEC) + && !s_refreshed_stale) why = "stale>1d"; + else if (expired && !s_refreshed_expired) why = "expired"; + if (!why) return; + + ncm_vip_info_t info; + int rc = ncm_api_vip_info(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid, &info); + /* 无论成败都置单飞标志:本条件本启动只查一次(失败等下次启动/下个条件,防风控) */ + if (force) s_refreshed_login = 1; + if (strcmp(why, "stale>1d") == 0) s_refreshed_stale = 1; + if (strcmp(why, "expired") == 0) s_refreshed_expired = 1; + if (rc != NCM_OK) { + meow_log(MEOW_LOG_WARN, "account: vip refresh (%s) failed rc=%d, keep cached vip=%d", + why, rc, g_st.vip); + return; + } + + g_st.vip = info.vip; + g_st.vip_expire_ms = (info.vip == NCM_VIP_SVIP) ? info.svip_expire_ms + : (info.vip == NCM_VIP_CVIP) ? info.cvip_expire_ms : 0; + g_st.vip_checked_at = now_s; + account_save(info.vip, info.cvip_expire_ms, info.svip_expire_ms, now_s); + meow_log(MEOW_LOG_INFO, "account: vip refresh (%s) -> vip=%d expire=%lld", + why, info.vip, (long long)g_st.vip_expire_ms); +} diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 0f32c79..28a7311 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -35,6 +35,8 @@ int appstate_init(void) meow_config_get("ncm.uid", g_st.uid, sizeof g_st.uid); meow_config_get("ncm.nickname", g_st.nickname, sizeof g_st.nickname); + account_load(); /* §20:恢复会员态缓存(account.json;无文件则 NCM_VIP_NONE) */ + return 0; } @@ -44,9 +46,11 @@ void appstate_load_settings(void) if (g_st.cache_limit_gb < 1) g_st.cache_limit_gb = 1; if (g_st.cache_limit_gb > 100) g_st.cache_limit_gb = 100; - g_st.level = meow_config_int("ncm.level", NCM_LEVEL_HIRES); - if (g_st.level < NCM_LEVEL_STANDARD || g_st.level > NCM_LEVEL_HIRES) - g_st.level = NCM_LEVEL_HIRES; + /* §20 默认档 = 极高 HQ(EXHIGH,用户定案 NCM_CACHE_DESIGN §3;旧默认 Hi-Res 改掉)。 + * 已持久化的合法旧值(含 HIRES 等)原样保留;越界值回落默认档。 */ + g_st.level = meow_config_int("ncm.level", NCM_LEVEL_EXHIGH); + if (g_st.level < NCM_LEVEL_STANDARD || g_st.level > NCM_LEVEL_UI_MAX) + g_st.level = NCM_LEVEL_EXHIGH; ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); g_st.ctx.default_level = g_st.level; @@ -81,6 +85,14 @@ void appstate_logout(void) g_st.logged_in = 0; g_st.uid[0] = 0; g_st.nickname[0] = 0; + g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */ + g_st.vip_expire_ms = 0; + g_st.vip_checked_at = 0; + { + char ap[600]; + snprintf(ap, sizeof ap, "%s/account.json", g_st.data_dir); + remove(ap); + } g_st.active_playlist = -1; g_st.playing_song = -1; pthread_mutex_lock(&g_song_mu); @@ -435,9 +447,10 @@ static int autoplay_try_playlist(int pl) for (int i = 0; i < g_st.track_ids_count; i++) { /* 滑窗模型:锁内拷贝窗内有效槽 */ ncm_song_t s; if (appstate_song_copy(i, &s) != 0) break; /* 出窗即停(首页之后未物化) */ - /* §19 缓存键 = id+档位:先试当前档位键(flac/mp3),再回退旧无档位键(存量 mp3) */ + /* §19 缓存键 = id+档位(§20 起用 level_key:sky 与 sky_c51 可区分): + * 先试当前档位键(flac/mp3),再回退旧无档位键(存量 mp3) */ char key[64]; - snprintf(key, sizeof key, "%s_%s", s.id, ncm_api_level_str(g_st.level)); + snprintf(key, sizeof key, "%s_%s", s.id, ncm_api_level_key(g_st.level)); if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", cpath, sizeof cpath) == NCM_OK || ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", cpath, sizeof cpath) == NCM_OK || ncm_cache_lookup(&g_st.ctx.cache, s.id, "mp3", cpath, sizeof cpath) == NCM_OK) { diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index fc37e65..1dce553 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -89,6 +89,7 @@ typedef struct ncm_play_run { int64_t dl_done; /* 本曲已下载字节(缓冲期进度反馈,§18) */ int64_t dl_total; /* 本曲标称大小(song_url 返回;0=未知) */ int has_track; /* 当前是否有可播曲目(含 url 获取中) */ + int eff_level; /* 实际生效音质档(§20;≠用户档时播放页标注「已降级至 xx」;-1=未定) */ /* 播放曲快照(REPORT §17):run_song 起播时把歌曲对象整体拷入(mu 保护), * 之后播放/歌词/IPC/UI 全读快照而非窗口——窗口滑出当前播放曲也不影响播放。 */ ncm_song_t play_song; @@ -115,6 +116,10 @@ typedef struct ncm_app_state { /* 账号 */ char uid[32]; char nickname[128]; + /* 会员态(§20,account.json 落盘:data/account.json;刷新=登录后/超 1 天/到期后一次) */ + int vip; /* NCM_VIP_*(NONE/CVIP/SVIP) */ + int64_t vip_expire_ms; /* 当前档到期时间(ms;0=无/未知) */ + int64_t vip_checked_at; /* vip/info 最近获取时间(秒) */ /* 我的歌单 */ ncm_playlist_t playlists[NCM_PLAYLIST_MAX]; @@ -290,6 +295,13 @@ int appstate_song_fetch(int gidx, ncm_song_t *out); /* 任意全局下标物化 * 不滑窗(播放线程用,避免与浏览视口抢窗口) */ void appstate_adopt_songs(int n); /* 收编外部已填的 songs[0..n)(搜索结果)为窗口 base=0, * 同步 song_ids/track_ids_count(播放上下文) */ + +/* account(app/logic/account.c,§20 会员态):account.json 落盘 + 刷新策略 + * (登录后/缓存超 1 天/到期后 各查一次,不轮询——风控)。 */ +void account_load(void); /* appstate_init 时读 account.json 恢复 vip 态 */ +int account_vip(void); /* 当前有效会员档(到期自动降档视图,不联网) */ +int account_level_cap(void); /* 会员态允许的最高音质档(NONE→EXHIGH/CVIP→HIRES/SVIP→SURROUND) */ +void account_vip_maybe_refresh(int force); /* worker 线程上下文调用(含网络,单次) */ void appstate_apply_playlist_cover(int idx); void appstate_set_playing_song(int idx, const char *cover_url); int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ @@ -340,6 +352,11 @@ int player_manager_load_lyric(void); /* 为当前曲目拉歌词并 int player_manager_error(void); int player_manager_current_song(ncm_song_t *out); /* 播放曲快照锁内拷贝;0=有效 -1=无(REPORT §17) */ void player_manager_dl(int64_t *done, int64_t *total); /* 本曲下载进度(字节;total=0 未知,§18) */ +int player_manager_eff_level(void); /* 实际生效音质档(§20;-1=未定) */ + +/* 音质档位显示名/规格(ui/chrome.c,§20 八档 UI 权威口径;settings/player 共用) */ +const char *ncm_level_name(int lv); /* 短名:标准/极高 HQ/无损 SQ/Hi-Res/高清臻音/臻音全景声/超清母带/沉浸环绕声 */ +const char *ncm_level_spec(int lv); /* 规格:128kbps / ≤320kbps / 48kHz·16bit / ... */ /* songmeta(app/logic/songmeta.c):歌曲元数据本地缓存(/meta/.bin), * 拉过的 song_detail 结果序列化落盘,再次入窗免重复请求(REPORT §17)。 */ diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index ab3df29..f0b673a 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -1,5 +1,5 @@ -/* play/player_manager.c — 播放线程:URL 获取(Hi-res 逐档降级)→缓存(命中直接播/否则流式落盘)→ - * minimp3 解码→audio_out 直写。UI 不卡(网络+解码全在此线程)。 +/* play/player_manager.c — 播放线程:URL 获取(全档表逐级降级)→缓存(命中直接播/否则流式落盘)→ + * minimp3/dr_flac 解码→audio_out 直写。UI 不卡(网络+解码全在此线程)。 * * 模型:一个持久的播放线程。外层循环取「目标曲目」,run_song() 负责一首歌的 * 「下载(缓冲) → 解码 → 输出 → 结束/被指令打断」。暂停=冻结(停读停写停推 pos,状态置 PAUSED); @@ -9,6 +9,8 @@ * §19:解码经 decoder.c 按 magic 分发(fLaC→dr_flac / 其它→minimp3);rate/ch/bits 按实际流 * 给 audio_out(flac ≤16bit→S16,>16bit→S32;audiod 格式归一/DAC 原生率跟随在服务端); * 音频缓存键 = 歌曲ID+音质档位(用户定案 NCM_CACHE_DESIGN §2.5),旧无档位键温和兼容。 + * §20:音质体系全量——用户档超会员权限不硬拒(钳顶后逐级降级,eff_level 回填供播放页 + * 「已降级至 xx」标注;下一首回到用户档);fee∈{1,4} 且非会员 → NCM_ERR_VIP 不发请求。 */ #include "../ncm_app_internal.h" #include @@ -44,6 +46,9 @@ int64_t player_manager_pos_ms(void) { int64_t p; pthread_mutex_lock(&g_play.mu); int64_t player_manager_dur_ms(void) { int64_t d; pthread_mutex_lock(&g_play.mu); d = g_play.dur_ms; pthread_mutex_unlock(&g_play.mu); return d; } int player_manager_error(void) { int e; pthread_mutex_lock(&g_play.mu); e = g_play.err; pthread_mutex_unlock(&g_play.mu); return e; } +/* 实际生效音质档(§20):下载时由 song_url_auto 回填;缓存命中按 ext 推断;-1=未定。 */ +int player_manager_eff_level(void) { int e; pthread_mutex_lock(&g_play.mu); e = g_play.eff_level; pthread_mutex_unlock(&g_play.mu); return e; } + /* 本曲下载进度(UI 缓冲反馈,§18)。total=0 表示未知(song_url 未给 size / 缓存命中)。 */ void player_manager_dl(int64_t *done, int64_t *total) { @@ -86,6 +91,7 @@ void player_manager_play(int song_idx) g_play.dl_done = 0; g_play.dl_total = 0; g_play.seq++; + g_play.eff_level = -1; /* 新播放意图:生效档未定(song_local_file 回填) */ pthread_mutex_unlock(&g_play.mu); } @@ -120,6 +126,7 @@ void player_manager_next(int dir) g_play.dl_done = 0; g_play.dl_total = 0; g_play.seq++; + g_play.eff_level = -1; /* 切歌:生效档随新曲重估(下首恢复用户档,§20) */ } pthread_mutex_unlock(&g_play.mu); } @@ -146,16 +153,24 @@ static int download_cb(void *ctx, const unsigned char *data, size_t len) } /* 缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 音质档位 → "song__."。 - * 同歌同档命中不重复下载;换档=新条目,旧档留给 LRU 自然淘汰。 */ + * 同歌同档命中不重复下载;换档=新条目,旧档留给 LRU 自然淘汰。 + * §20:键后缀用 ncm_api_level_key(sky 与 sky_c51 可区分)。 */ static void audio_cache_key(char *out, size_t sz, const char *id, int level) { - snprintf(out, sz, "%s_%s", id, ncm_api_level_str(level)); + snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level)); } /* 得到本地音频路径 + 文件大小。命中缓存直接返回;否则 URL 降级 + 流式落缓存。 - * 返回 NCM_OK 或负错误码。id 由调用方(run_song)从播放曲快照取,与窗口解耦(§17)。 */ -static int song_local_file(const char *id, char *path, size_t psz, int64_t *bytes) + * 返回 NCM_OK 或负错误码。s 为播放曲快照(run_song 物化,与窗口解耦 §17)。 + * §20 权限/会员: + * - fee 门禁(BIZ_NOTES §3 社区口径):fee∈{1,4}(VIP 才能听/付费专)且当前非会员 + * → 直接 NCM_ERR_VIP,**不发 song_url 请求**(省一次请求,防风控);fee=0/8 放行 + * (8=免费听低音质,播放允许)。本地缓存命中优先于门禁(缓存可播不拦)。 + * - VIP 档上限:用户档超会员权限不硬拒,播放时先钳到会员允许的最高档再逐级降级 + * (免去注定空 URL 的高档请求);实际生效档回填 g_play.eff_level。 */ +static int song_local_file(const ncm_song_t *s, char *path, size_t psz, int64_t *bytes) { + const char *id = s->id; char key[64]; audio_cache_key(key, sizeof key, id, g_level); @@ -163,20 +178,51 @@ static int song_local_file(const char *id, char *path, size_t psz, int64_t *byte 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) { struct stat st; - if (stat(path, &st) == 0) { *bytes = (int64_t)st.st_size; return NCM_OK; } + if (stat(path, &st) == 0) { + *bytes = (int64_t)st.st_size; + /* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原): + * mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。 + * (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。) */ + 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; + } } /* 旧键温和兼容(§19 前存量:无档位后缀的 song_[.]mp3,含历史双点名):命中即播, * 不主动改名/删除,由 LRU 自然淘汰。 */ if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) { struct stat st; - if (stat(path, &st) == 0) { *bytes = (int64_t)st.st_size; return NCM_OK; } + if (stat(path, &st) == 0) { + *bytes = (int64_t)st.st_size; + pthread_mutex_lock(&g_play.mu); + g_play.eff_level = (g_level > NCM_LEVEL_EXHIGH) ? NCM_LEVEL_EXHIGH : g_level; + pthread_mutex_unlock(&g_play.mu); + return NCM_OK; + } } - /* 播放地址:从 g_level 降级,接受 mp3 与 flac(§19:dr_flac 落地后无损/Hi-Res 真实生效) */ + /* fee 门禁:VIP 才能听(1=VIP 歌曲 / 4=付费数字专辑)且非会员 → 不请求 url。 + * (必须在缓存查找之后:已缓存的过期/下架曲本地可播,NCM_CACHE_DESIGN §2.4。) */ + if ((s->fee == 1 || s->fee == 4) && account_vip() < NCM_VIP_CVIP) + return NCM_ERR_VIP; + + /* 播放地址:用户档先按会员权限钳顶,再逐档降级(§20 全档表),接受 mp3 与 flac */ + int want = g_level; + int vip_cap = account_level_cap(); + if (want > vip_cap) want = vip_cap; ncm_play_url_t u; - int rc = ncm_api_song_url_auto(g_st.ctx.device_id, g_st.ctx.cookie, id, g_level, &u); + int eff = -1; + int rc = ncm_api_song_url_auto(g_st.ctx.device_id, g_st.ctx.cookie, id, 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 >= 0) ? eff : want; + pthread_mutex_unlock(&g_play.mu); /* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */ const char *ext = "mp3"; if (ncm_streq(u.type, "flac")) ext = "flac"; @@ -260,11 +306,12 @@ static void run_song(int idx) int64_t file_bytes = 0; char path[512]; - int rc = song_local_file(s.id, path, sizeof path, &file_bytes); + int rc = song_local_file(&s, path, sizeof path, &file_bytes); if (rc != NCM_OK) { if (!still_current(idx)) return; /* 下载期间已切歌:静默放弃(缓存已落盘不亏) */ - const char *m = (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)" - : (rc == NCM_ERR_AUTH) ? "session expired" : "load failed"; + const char *m = (rc == NCM_ERR_VIP) ? "VIP only (fee gate)" + : (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)" + : (rc == NCM_ERR_AUTH) ? "session expired" : "load failed"; set_err(rc, m); return; } @@ -295,8 +342,9 @@ static void run_song(int idx) g_play.dur_ms = dur; g_play.err = 0; pthread_mutex_unlock(&g_play.mu); - meow_log(MEOW_LOG_INFO, "play: start idx=%d id=%s fmt=%s %dHz/%dch/%dbit dur=%lldms", - idx, s.id, ncm_dec_format(dec), rate, ch, bits, (long long)dur); + meow_log(MEOW_LOG_INFO, "play: start idx=%d id=%s fmt=%s %dHz/%dch/%dbit dur=%lldms level=%s eff=%s", + idx, s.id, ncm_dec_format(dec), rate, ch, bits, (long long)dur, + ncm_api_level_key(g_level), ncm_api_level_key(g_play.eff_level >= 0 ? g_play.eff_level : g_level)); /* 单块缓冲:4096 帧 × ≤2ch × 4B(S32 上限)= 32KB,静态(播放线程单实例,省 32KB 栈)。 */ static int32_t pcm[PCM_CHUNK_FRAMES * 2]; diff --git a/patch_v2/ncm_app/app/ui/chrome.c b/patch_v2/ncm_app/app/ui/chrome.c index 5fa540e..41fade2 100644 --- a/patch_v2/ncm_app/app/ui/chrome.c +++ b/patch_v2/ncm_app/app/ui/chrome.c @@ -103,3 +103,37 @@ void chrome_tick(void) s_snack_until = 0; } } + +/* ---- §20 音质档位显示名/规格(八档 UI 权威口径,NCM_CACHE_DESIGN §3;settings/player 共用)---- + * 档位中文名以我方设计文档为准(BIZ_NOTES §2.1:CNMPlayer 标签只做 level 串对齐参考)。 */ +const char *ncm_level_name(int lv) +{ + switch (lv) { + case NCM_LEVEL_SURROUND: return "沉浸环绕声"; + case NCM_LEVEL_SKY: return "臻音全景声"; + case NCM_LEVEL_JYMASTER: return "超清母带"; + case NCM_LEVEL_JYEFFECT: return "高清臻音"; + case NCM_LEVEL_DOLBY: return "杜比全景声"; /* 映射保留,不进 UI 清单 */ + case NCM_LEVEL_HIRES: return "Hi-Res"; + case NCM_LEVEL_LOSSLESS: return "无损 SQ"; + case NCM_LEVEL_EXHIGH: return "极高 HQ"; + case NCM_LEVEL_HIGHER: return "较高"; /* 仅降级链中间档,不进 UI */ + default: return "标准"; + } +} + +const char *ncm_level_spec(int lv) +{ + switch (lv) { + case NCM_LEVEL_SURROUND: return "5.1 声道"; + case NCM_LEVEL_SKY: return "Audio Vivid"; + case NCM_LEVEL_JYMASTER: return "192kHz/24bit"; + case NCM_LEVEL_JYEFFECT: return "96kHz/24bit"; + case NCM_LEVEL_DOLBY: return "Dolby Atmos"; + case NCM_LEVEL_HIRES: return "192kHz/24bit"; + case NCM_LEVEL_LOSSLESS: return "48kHz/16bit"; + case NCM_LEVEL_EXHIGH: return "≤320kbps"; + case NCM_LEVEL_HIGHER: return "192kbps"; + default: return "128kbps"; + } +} diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index 64f6cb8..601482f 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -208,6 +208,8 @@ static void *home_worker(void *arg) int rc; if (!g_st.logged_in) rc = NCM_ERR_AUTH; else rc = appstate_load_playlists(); + if (rc == NCM_OK) + account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */ pthread_mutex_lock(&g_home_mu); g_load_err = rc; g_home_loading = 0; diff --git a/patch_v2/ncm_app/app/ui/login.c b/patch_v2/ncm_app/app/ui/login.c index 9861d61..9539fcc 100644 --- a/patch_v2/ncm_app/app/ui/login.c +++ b/patch_v2/ncm_app/app/ui/login.c @@ -154,6 +154,7 @@ static void *login_worker(void *arg) meow_log(MEOW_LOG_INFO, "login: status_check rc=%d uid=%s nick=%s", src, uid, nick); appstate_set_logged_in(src == NCM_OK ? uid : "", src == NCM_OK ? nick : ""); g_st.logged_in = 1; + account_vip_maybe_refresh(1); /* §20:登录后查一次会员态(本 worker 线程,不卡 UI) */ g_done = 1; meow_log(MEOW_LOG_INFO, "login: -> home"); break; diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c index a85015c..e32d2b8 100644 --- a/patch_v2/ncm_app/app/ui/player.c +++ b/patch_v2/ncm_app/app/ui/player.c @@ -26,6 +26,7 @@ static int g_custom_disc, g_custom_dyn; static int g_landscape; static int g_last_pos_ms, g_last_state, g_last_line, g_last_song; static int g_last_dl, g_last_err; /* 缓冲进度粒度值 / 错误沿(§18) */ +static int g_last_eff; /* 生效档沿(§20:降级标注出现/消失刷动态带) */ static char g_last_meta_id[24]; /* 已上封面的元数据 id(快照滞后 settle 时纠正封面/歌名) */ /* 运行期几何(player_compute_geom 填充;各带绘制物/命中共用)。 */ @@ -232,6 +233,22 @@ static void plr_draw_dyn(void *ctx) meow_ui_fill_rect(g_bar.x, g_bar.y, g_bar.w, g_bar.h, th->surfaceVariant); meow_ui_fill_rect(g_bar.x, g_bar.y, (int)((int64_t)g_bar.w * frac / 1000), g_bar.h, pa->red); + /* §20:实际生效音质档(与用户档不同时小字标注「已降级至 xx」;缓冲期未定不显示)。 + * 位置:时间行中部(左右是时间戳,中央留白),竖/横屏同一条带。 */ + { + int eff = player_manager_eff_level(); + if (have && eff >= 0 && player_manager_state() != NCM_PC_STOPPED) { + char qb[72]; + if (eff != g_st.level) + snprintf(qb, sizeof qb, "已降级至 %s", ncm_level_name(eff)); + else + snprintf(qb, sizeof qb, "%s", ncm_level_name(eff)); + int qcx = g_landscape ? (g_bar.x + g_bar.w / 2) : (r_cont.x + r_cont.w / 2); + uint32_t qc = (eff != g_st.level) ? pa->red : th->onSurfaceVariant; + meow_ui_text_center(qcx, g_times_y, qb, 18, qc); + } + } + int st = player_manager_state(); draw_ctl(g_cprev_x, g_ctl_y, g_cr - 6, 0, th->surfaceContainer, th->onSurface); /* 暂停/停止(出错待重试)都画「播放」三角;播放/缓冲画暂停符 */ @@ -248,7 +265,8 @@ static void plr_draw_dyn(void *ctx) int perr = player_manager_error(); if (perr != 0) { /* §18:播放错误明示(旧版 err 无 UI 消费者 → 失败无声死等) */ - const char *em = (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)" + const char *em = (perr == NCM_ERR_VIP) ? "VIP 专享,开通会员后可播放" + : (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)" : (perr == NCM_ERR_AUTH) ? "登录已过期,请重新登录" : "加载失败,请检查网络"; meow_ui_text_center(lcx, center_y - 20, em, 20, pa->red); @@ -423,7 +441,14 @@ void player_tick(uint64_t now) if (perr != g_last_err) { g_last_err = perr; meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); - if (perr) ncm_snack("播放失败"); + if (perr == NCM_ERR_VIP) ncm_snack("VIP 专享歌曲"); + else if (perr) ncm_snack("播放失败"); + } + /* §20:生效档变化(降级判定完成/切歌重置)刷动态带。 */ + int eff = player_manager_eff_level(); + if (eff != g_last_eff) { + g_last_eff = eff; + meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); } } @@ -440,7 +465,7 @@ static void plr_page_create(meow_view *v, void *ctx) g_custom_dyn = meow_draw_custom_register(11, g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h, plr_draw_dyn, NULL); g_last_pos_ms = -1; g_last_state = -1; g_last_line = -1; g_last_song = -1; - g_last_dl = -1; g_last_err = 0; + g_last_dl = -1; g_last_err = 0; g_last_eff = -2; /* -2: 与 eff=-1(未定) 区分,首 tick 必刷 */ g_last_meta_id[0] = 0; } diff --git a/patch_v2/ncm_app/app/ui/settings.c b/patch_v2/ncm_app/app/ui/settings.c index bd78588..b40afb7 100644 --- a/patch_v2/ncm_app/app/ui/settings.c +++ b/patch_v2/ncm_app/app/ui/settings.c @@ -1,8 +1,10 @@ /* ui/settings.c — 设置页(ACT_SETTINGS)。 * - * 行项:音质(Hi-res 默认,单曲不可用时 core ncm_api_song_url_auto 逐档降级)+ 缓存上限 - * (默认 10GB,上限 100GB,支持预设与「自定义」数字输入)+ 清理缓存(显示当前占用)+ - * 退出登录 + 关于/版本。设置持久化到 MeowData/dev.player.ncm/data(meow_config_*,与 core + * 行项:音质(§20 八档:标准/极高 HQ/无损 SQ/Hi-Res/高清臻音/臻音全景声/超清母带/沉浸环绕声, + * 默认极高 HQ;每档标注规格+会员门槛 CVIP/SVIP;超权限档允许选定,播放时钳顶+逐级降级, + * 见 player_manager.c song_local_file)+ 缓存上限(默认 10GB,上限 100GB,支持预设与 + * 「自定义」数字输入)+ 清理缓存(显示当前占用)+ 退出登录 + 关于/版本。 + * 设置持久化到 MeowData/dev.player.ncm/data(meow_config_*,与 core * ncm_store_read_cfg_int 同目录同键,ncm.cache_limit_gb/ncm.level)。超上限触发 ncm_cache LRU * 淘汰(写缓存前后 + 手动调低上限时,见 player_manager.c/cover.c/appstate.c)。 * 空态/绘制用 custom 绘制物 + 手动行命中(仅 5 行,无需列表滚动)。 @@ -24,16 +26,12 @@ static uint64_t g_cache_used; /* 缓存占用字节(进设置页时 #define SETTINGS_ROWS 5 -static const char *level_name(int lv) -{ - switch (lv) { - case NCM_LEVEL_HIRES: return "Hi-Res"; - case NCM_LEVEL_LOSSLESS:return "无损"; - case NCM_LEVEL_EXHIGH: return "极高"; - case NCM_LEVEL_HIGHER: return "较高"; - default: return "标准"; - } -} +/* 八档音质 UI 表(用户手机 App 口径为权威,NCM_CACHE_DESIGN §3):显示顺序高→低。 + * HIGHER(较高)与 DOLBY(杜比全景声)不进 UI 清单(映射保留,见 ncm_api.h)。 */ +static const int g_ui_levels[8] = { + NCM_LEVEL_SURROUND, NCM_LEVEL_JYMASTER, NCM_LEVEL_SKY, NCM_LEVEL_JYEFFECT, + NCM_LEVEL_HIRES, NCM_LEVEL_LOSSLESS, NCM_LEVEL_EXHIGH, NCM_LEVEL_STANDARD, +}; /* 缓存占用字节 → 人类可读(整数运算,不用浮点/软浮点)。buf ≥ 24。 */ static void fmt_bytes(uint64_t b, char *buf, size_t sz) @@ -80,7 +78,19 @@ static void settings_draw(void *ctx) rows[2].title = "清理缓存"; rows[2].red = 0; rows[3].title = "退出登录"; rows[3].red = 1; rows[4].title = "关于网易云"; rows[4].red = 0; - snprintf(rows[0].sub, sizeof rows[0].sub, "当前: %s", level_name(g_st.level)); + { + /* 当前档 + 会员门槛标注;超权限(未解锁)时提示将降级(不硬拒,§20) */ + int gate = ncm_api_level_gate(g_st.level); + const char *gate_s = (gate == NCM_VIP_SVIP) ? "SVIP" : (gate == NCM_VIP_CVIP) ? "CVIP" : ""; + int locked = (account_vip() < gate); + if (gate_s[0]) + snprintf(rows[0].sub, sizeof rows[0].sub, "当前: %s (%s) · 需 %s%s", + ncm_level_name(g_st.level), ncm_level_spec(g_st.level), gate_s, + locked ? ",将降级" : ""); + else + snprintf(rows[0].sub, sizeof rows[0].sub, "当前: %s (%s)", + ncm_level_name(g_st.level), ncm_level_spec(g_st.level)); + } snprintf(rows[1].sub, sizeof rows[1].sub, "当前: %d GB · 上限 100", g_st.cache_limit_gb); snprintf(rows[2].sub, sizeof rows[2].sub, "占用: %s", usetxt); snprintf(rows[3].sub, sizeof rows[3].sub, "%s", g_st.nickname[0] ? g_st.nickname : "退出"); @@ -98,7 +108,7 @@ static void settings_draw(void *ctx) /* 品牌红页脚小字。 */ int ny = y + SETTINGS_ROWS * (g_row_h + 8) + 4; - meow_ui_text(x, ny, "Hi-Res/无损需黑胶会员,不支持时自动降级", 20, th->onSurfaceVariant); + meow_ui_text(x, ny, "无损/Hi-Res 需 CVIP ・ 臻音/母带需 SVIP,不支持时自动降级", 20, th->onSurfaceVariant); meow_ui_text(x, ny + 24, "网易云音乐 · MeowOS 移植版", 20, pa->red); } @@ -149,16 +159,27 @@ int settings_on_tap(int x, int y) int row = row_at(x, y); if (row < 0) return 0; - if (row == 0) { /* 音质:Top 档选择,单曲不可用时 core 逐档降级 */ - static const char *lbls[5] = { "Hi-Res", "无损", "极高", "较高", "标准" }; - static const int lvls[5] = { NCM_LEVEL_HIRES, NCM_LEVEL_LOSSLESS, - NCM_LEVEL_EXHIGH, NCM_LEVEL_HIGHER, NCM_LEVEL_STANDARD }; + if (row == 0) { /* 音质:八档(§20;超权限档可选不硬拒,播放时钳顶+逐级降级) */ meow_sheet *s = meow_sheet_create("选择音质", 0); - for (int i = 0; i < 5; i++) meow_sheet_add_item(s, lbls[i]); + int vip = account_vip(); + for (int i = 0; i < 8; i++) { + int lv = g_ui_levels[i]; + int gate = ncm_api_level_gate(lv); + char lbl[96]; + /* 每档标注 规格+会员门槛;未解锁档标注「需 CVIP/SVIP」(sheet 无置灰能力, + * 以文字标注代替——允许选定,实际播放逐级降级到可用档) */ + const char *gate_s = (gate == NCM_VIP_SVIP) ? "SVIP" : (gate == NCM_VIP_CVIP) ? "CVIP" : NULL; + if (gate_s) + snprintf(lbl, sizeof lbl, "%s (%s) · %s%s", ncm_level_name(lv), + ncm_level_spec(lv), gate_s, (vip < gate) ? "·未解锁" : ""); + else + snprintf(lbl, sizeof lbl, "%s (%s)", ncm_level_name(lv), ncm_level_spec(lv)); + meow_sheet_add_item(s, lbl); + } int op = meow_sheet_open(s); meow_sheet_free(s); - if (op >= 0 && op < 5) { - g_st.level = lvls[op]; + if (op >= 0 && op < 8) { + g_st.level = g_ui_levels[op]; player_manager_set_quality(g_st.level, g_st.cache_limit_gb); appstate_save_settings(); meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); diff --git a/patch_v2/ncm_app/bench/degrade_sim.c b/patch_v2/ncm_app/bench/degrade_sim.c new file mode 100644 index 0000000..fe789a5 --- /dev/null +++ b/patch_v2/ncm_app/bench/degrade_sim.c @@ -0,0 +1,165 @@ +/* bench/degrade_sim.c — song_url_auto 全档降级链离线矩阵(§20,0 真实请求)。 + * + * 驱动真实 core/ncm_api.c 的 ncm_api_song_url_auto_f(注入假 try_fn,按「可用下限」 + * 模型应答:level <= avail_from 才给 url)。覆盖: + * ① desired × avail_from 全矩阵(9 档 × 9 档 + 全不可用):命中档 = min(desired, avail_from), + * 请求数 = desired - 命中 + 1(逐级试,不多不少); + * ② desired 越界钳制(DOLBY=16 → SURROUND;负数 → STANDARD); + * ③ url 在但 type 未知(如 aac)→ 跳过继续降; + * ④ type 缺失时按 url 扩展名收 flac; + * ⑤ out_level 回填值与命中档一致(「下首恢复用户档」由调用方重传 desired 保证, + * 链内不记忆——每次调用独立从 desired 起步)。 + * + * 编译(宿主,ncm_app 目录下,先跑过 build_core.sh 产出 out/core-host/ 全部对象): + * gcc -O2 -Wall -Wextra -DJSMN_HEADER -Icore -Ivendor/jsmn \ + * bench/degrade_sim.c out/core-host/全部.o \ + * -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -o degrade_sim -lpthread -lm + * 运行:./degrade_sim (PASS/FAIL 退出码) + */ +#include "ncm.h" +#include +#include + +static int g_fail = 0; +#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) + +/* ---- 假逐档取址:level <= g_avail_from 给 url(type 可配),记录每档请求次数 ---- */ +static int g_avail_from; /* 可用下限;-1=全档不可用 */ +static const char *g_type = "mp3"; /* 应答 type(""=缺省看扩展名) */ +static const char *g_resp_level = NULL; /* 非 NULL:响应 data[0].level 固定串(服务端静默降档模拟) */ +static int g_calls[NCM_LEVEL_SURROUND + 1]; + +static int fake_try(const char *dev, const char *ck, const char *id, int level, + ncm_play_url_t *out) +{ + (void)dev; (void)ck; (void)id; + if (level >= 0 && level <= NCM_LEVEL_SURROUND) g_calls[level]++; + memset(out, 0, sizeof *out); + if (level > g_avail_from) return NCM_OK; /* 该档不可用:url 空 */ + snprintf(out->url, sizeof out->url, "http://sim/%lld.%s", (long long)12345, + (level >= NCM_LEVEL_LOSSLESS) ? "flac" : "mp3"); + snprintf(out->type, sizeof out->type, "%s", g_type); + if (g_resp_level) snprintf(out->level, sizeof out->level, "%s", g_resp_level); + out->br = 320000; + out->size = 9000000; + return NCM_OK; +} + +static int g_bad_type_from = -1; /* >=0: 该档以上给 aac(未知格式应跳过) */ +static int fake_try_badtype(const char *dev, const char *ck, const char *id, int level, + ncm_play_url_t *out) +{ + int r = fake_try(dev, ck, id, level, out); + if (r == NCM_OK && out->url[0] && level >= g_bad_type_from) + snprintf(out->type, sizeof out->type, "aac"); + return r; +} + +static void reset_calls(void) { memset(g_calls, 0, sizeof g_calls); } +static int total_calls(void) +{ + int n = 0; + for (int i = 0; i <= NCM_LEVEL_SURROUND; i++) n += g_calls[i]; + return n; +} + +int main(void) +{ + ncm_play_url_t u; + int eff; + + printf("== 1) desired × avail_from 全矩阵 ==\n"); + int cases = 0, bad = 0; + for (int desired = NCM_LEVEL_STANDARD; desired <= NCM_LEVEL_SURROUND; desired++) { + for (int avail = NCM_LEVEL_STANDARD; avail <= NCM_LEVEL_SURROUND; avail++) { + reset_calls(); + g_avail_from = avail; g_type = "mp3"; + int rc = ncm_api_song_url_auto_f("dev", "ck", "1", desired, &u, &eff, fake_try); + int want = (avail < desired) ? avail : desired; /* 期望命中 = min */ + int ok = (rc == NCM_OK) && (eff == want) && u.url[0] && + (total_calls() == desired - want + 1); + /* 高档(>命中)各恰好 1 次,低档不请求 */ + for (int lv = want + 1; lv <= desired; lv++) ok = ok && (g_calls[lv] == 1); + for (int lv = 0; lv < want; lv++) ok = ok && (g_calls[lv] == 0); + cases++; + if (!ok) { + bad++; + printf(" FAIL desired=%d(%s) avail=%d -> rc=%d eff=%d calls=%d (want %d/%d)\n", + desired, ncm_api_level_str(desired), avail, rc, eff, total_calls(), + want, desired - want + 1); + g_fail = 1; + } + } + /* 全不可用 → NOTFOUND,且恰好 desired+1 次请求 */ + reset_calls(); + g_avail_from = -1; g_type = "mp3"; + int rc = ncm_api_song_url_auto_f("dev", "ck", "1", desired, &u, &eff, fake_try); + int ok = (rc == NCM_ERR_NOTFOUND) && (eff == -1) && + (total_calls() == desired - NCM_LEVEL_STANDARD + 1); + cases++; + if (!ok) { + bad++; + printf(" FAIL desired=%d all-unavail -> rc=%d eff=%d calls=%d\n", + desired, rc, eff, total_calls()); + g_fail = 1; + } + } + CHECK(bad == 0, "matrix %d cases all pass (desired 9 x avail 9 + 全不可用 9)", cases); + + printf("== 2) desired 越界钳制 ==\n"); + reset_calls(); + g_avail_from = NCM_LEVEL_SURROUND; g_type = "mp3"; + CHECK(ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_DOLBY, &u, &eff, fake_try) == NCM_OK && + eff == NCM_LEVEL_SURROUND && total_calls() == 1 && g_calls[NCM_LEVEL_SURROUND] == 1, + "desired=DOLBY(16) clamped to SURROUND (eff=%d calls=%d)", eff, total_calls()); + reset_calls(); + CHECK(ncm_api_song_url_auto_f("d", "c", "1", -7, &u, &eff, fake_try) == NCM_OK && + eff == NCM_LEVEL_STANDARD && total_calls() == 1, + "desired=-7 clamped to STANDARD (eff=%d)", eff); + + printf("== 3) 未知 type 跳过继续降 ==\n"); + reset_calls(); + g_avail_from = NCM_LEVEL_SURROUND; g_bad_type_from = NCM_LEVEL_HIRES; + /* hires 及以上 url 在但 type=aac → 跳过,落在 lossless */ + CHECK(ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_SKY, &u, &eff, fake_try_badtype) + == NCM_OK && eff == NCM_LEVEL_LOSSLESS, + "aac above hires -> land lossless (eff=%d)", eff); + + printf("== 4) type 缺失按 url 扩展名收 flac ==\n"); + reset_calls(); + g_avail_from = NCM_LEVEL_LOSSLESS; g_type = ""; + CHECK(ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_LOSSLESS, &u, &eff, fake_try) + == NCM_OK && strcmp(u.type, "flac") == 0, + "type empty + .flac url -> type backfilled flac"); + + printf("== 5) out_level 每次独立(下首恢复用户档语义) ==\n"); + reset_calls(); + g_avail_from = NCM_LEVEL_STANDARD; g_type = "mp3"; + ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_SURROUND, &u, &eff, fake_try); + int first_eff = eff; + reset_calls(); + g_avail_from = NCM_LEVEL_SURROUND; + ncm_api_song_url_auto_f("d", "c", "2", NCM_LEVEL_SURROUND, &u, &eff, fake_try); + CHECK(first_eff == NCM_LEVEL_STANDARD && eff == NCM_LEVEL_SURROUND, + "prev degraded to standard, next song back at surround (eff %d -> %d)", + first_eff, eff); + + printf("== 6) 响应 level 静默降档采信(data[0].level < 请求档) ==\n"); + reset_calls(); + g_avail_from = NCM_LEVEL_SURROUND; g_type = "flac"; g_resp_level = "exhigh"; + CHECK(ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_HIRES, &u, &eff, fake_try) == NCM_OK && + eff == NCM_LEVEL_EXHIGH, + "req hires, resp level=exhigh -> eff=exhigh (got %d)", eff); + reset_calls(); + g_resp_level = "sky"; /* sky 族有歧义:不采信,保守取请求档 */ + CHECK(ncm_api_song_url_auto_f("d", "c", "1", NCM_LEVEL_SURROUND, &u, &eff, fake_try) == NCM_OK && + eff == NCM_LEVEL_SURROUND, + "req surround, resp level=sky (歧义) -> eff=surround (got %d)", eff); + g_resp_level = NULL; + + printf(g_fail ? "== DEGRADE SIM: FAIL ==\n" : "== DEGRADE SIM: ALL PASS ==\n"); + return g_fail; +} diff --git a/patch_v2/ncm_app/bench/player_flow_sim.c b/patch_v2/ncm_app/bench/player_flow_sim.c index 24b2d88..dd1e76b 100644 --- a/patch_v2/ncm_app/bench/player_flow_sim.c +++ b/patch_v2/ncm_app/bench/player_flow_sim.c @@ -20,6 +20,9 @@ * gcc -O2 -Wl,--gc-sections -o player_flow_sim /tmp/pf.o /tmp/pf_pm.o -lpthread -lm * 运行:./player_flow_sim (0 网络;PASS/FAIL 退出码) * §19:P6 覆盖缓存键=歌曲ID+档位 + flac type → .flac 落盘扩展名。 + * §20:P7 覆盖 VIP 钳顶(非会员用户档 SURROUND → 请求档 EXHIGH,缓存键仍用户档 sky_c51; + * SVIP 直达)+ eff_level 回填;P8 覆盖 fee 门禁(fee=1 非会员 NCM_ERR_VIP 零请求 / + * 缓存命中绕过 / CVIP 放行 / fee=8 放行)。 */ #include "../app/ncm_app_internal.h" #include "../app/play/decoder.h" @@ -46,7 +49,8 @@ size_t ncm_strlcpy(char *dst, const char *src, size_t sz) void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; } void meow_audio_on_preempt(meow_audio_preempt_fn cb, void *ctx) { (void)cb; (void)ctx; } -/* ---- appstate stub:窗内全命中,id=1000+idx ---- */ +/* ---- appstate stub:窗内全命中,id=1000+idx;fee 可注入(§20 P8) ---- */ +static int g_fee = 0; int appstate_song_copy(int gidx, ncm_song_t *out) { if (gidx < 0 || gidx >= g_st.track_ids_count) return -1; @@ -54,6 +58,7 @@ int appstate_song_copy(int gidx, ncm_song_t *out) snprintf(out->id, sizeof out->id, "%d", 1000 + gidx); snprintf(out->name, sizeof out->name, "Song%d", gidx); out->duration_ms = 180000; + out->fee = g_fee; return 0; } int appstate_song_fetch(int gidx, ncm_song_t *out) { return appstate_song_copy(gidx, out) == 0 ? NCM_OK : NCM_ERR_ARG; } @@ -68,6 +73,20 @@ static int g_downloads; /* http_download 调用数(song_url 请求放 static const char *g_url_type = "mp3"; /* §19:song_url 注入的 type(mp3/flac) */ static char g_last_key[80]; /* ncm_cache_open 收到的键(档位键断言用) */ static char g_last_ext[8]; /* ncm_cache_open 收到的扩展名 */ +static int g_url_calls; /* §20:song_url_auto 调用数(fee 门禁不得发请求) */ +static int g_url_req_level = -1;/* §20:song_url_auto 收到的档位(VIP 钳顶断言用) */ +static int g_vip = NCM_VIP_NONE;/* §20:account stub 注入的会员态 */ + +/* §20:account stub(真实实现在 app/logic/account.c,不链入本 sim) */ +int account_vip(void) { return g_vip; } +int account_level_cap(void) +{ + switch (account_vip()) { + case NCM_VIP_SVIP: return NCM_LEVEL_SURROUND; + case NCM_VIP_CVIP: return NCM_LEVEL_HIRES; + default: return NCM_LEVEL_EXHIGH; + } +} int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz) { @@ -89,14 +108,19 @@ int ncm_cache_abort(ncm_cache_writer_t *w) { free(w); return NCM_OK; } int ncm_cache_evict(const ncm_cache_t *c) { (void)c; return NCM_OK; } void ncm_cache_set_limit_gb(ncm_cache_t *c, int gb) { (void)c; (void)gb; } -int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int level, ncm_play_url_t *out) +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) { - (void)dev; (void)ck; (void)id; (void)level; + (void)dev; (void)ck; (void)id; + g_url_calls++; + g_url_req_level = level; + if (out_level) *out_level = -1; if (g_url_rc != NCM_OK) return g_url_rc; memset(out, 0, sizeof *out); snprintf(out->url, sizeof out->url, "http://stub/%s.%s", id, g_url_type); snprintf(out->type, sizeof out->type, "%s", g_url_type); out->size = 4 * 1024 * 1024; + if (out_level) *out_level = level; return NCM_OK; } @@ -146,15 +170,19 @@ int ncm_dec_seek_ms(ncm_decoder_t *d, int64_t ms, int64_t fb, int64_t dur) void ncm_dec_close(ncm_decoder_t *d) { free(d); } const char *ncm_dec_format(const ncm_decoder_t *d) { (void)d; return "stub"; } -/* §19:song_local_file 的缓存键用 ncm_api_level_str(g_level);ext 判定用 ncm_streq */ +/* §19/§20:song_local_file 的缓存键用 ncm_api_level_key(g_level);ext 判定用 ncm_streq */ int ncm_streq(const char *a, const char *b) { if (!a || !b) return 0; return strcmp(a, b) == 0; } -const char *ncm_api_level_str(int level) +const char *ncm_api_level_key(int level) { switch (level) { + case NCM_LEVEL_SURROUND: return "sky_c51"; + case NCM_LEVEL_SKY: return "sky"; + case NCM_LEVEL_JYMASTER: return "jymaster"; + case NCM_LEVEL_JYEFFECT: return "jyeffect"; case NCM_LEVEL_HIRES: return "hires"; case NCM_LEVEL_LOSSLESS: return "lossless"; case NCM_LEVEL_EXHIGH: return "exhigh"; @@ -305,6 +333,61 @@ int main(void) player_manager_set_quality(NCM_LEVEL_STANDARD, 10); /* 还原,避免影响后续(无) */ g_eof = 1; + printf("== P7: VIP cap(§20 超权限档不硬拒,钳顶后降级;下首恢复用户档)==\n"); + usleep(300 * 1000); /* 等 P6 曲收尾 */ + g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_dl_delay_ms = 0; g_fee = 0; + player_manager_set_quality(NCM_LEVEL_SURROUND, 10); /* 用户档=沉浸环绕声(SVIP 档) */ + g_vip = NCM_VIP_NONE; g_url_calls = 0; g_url_req_level = -1; + player_manager_play(0); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "non-vip song playing (降级后照常播)"); + CHECK(g_url_req_level == NCM_LEVEL_EXHIGH, + "req level=%d (expect EXHIGH=%d:非会员钳顶,不浪费高档请求)", + 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); + /* SVIP:同一用户档不钳顶,直达 SURROUND */ + g_vip = NCM_VIP_SVIP; g_cache_ok = 0; g_url_calls = 0; g_url_req_level = -1; + player_manager_play(1); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "svip song playing"); + CHECK(g_url_req_level == NCM_LEVEL_SURROUND, + "req level=%d (expect SURROUND=%d:SVIP 不钳顶)", g_url_req_level, NCM_LEVEL_SURROUND); + CHECK(player_manager_eff_level() == NCM_LEVEL_SURROUND, + "eff_level=%d (expect SURROUND)", player_manager_eff_level()); + g_eof = 1; + + printf("== P8: fee 门禁(§20:fee∈{1,4} 且非会员 → NCM_ERR_VIP,不发 song_url)==\n"); + usleep(300 * 1000); + g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; + g_fee = 1; g_vip = NCM_VIP_NONE; g_url_calls = 0; + player_manager_play(2); + CHECK(wait_err(NCM_ERR_VIP, 2000), "fee=1 + 非会员 -> err=NCM_ERR_VIP (rc=%d)", + player_manager_error()); + CHECK(g_url_calls == 0, "song_url 零请求(省一次请求,防风控;calls=%d)", g_url_calls); + /* 已缓存的 VIP 曲:门禁不拦(缓存优先,NCM_CACHE_DESIGN §2.4) */ + g_cache_ok = 1; + player_manager_play(2); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "cached VIP song plays (缓存命中绕过门禁)"); + g_eof = 1; /* 先让它自然收尾(run_song 在跑时同 idx play 不重入) */ + wait_state(NCM_PC_STOPPED, 3000); + /* CVIP:fee=1 放行,正常发请求 */ + g_eof = 0; g_reads_left = -1; + g_cache_ok = 0; g_vip = NCM_VIP_CVIP; g_url_calls = 0; + player_manager_play(2); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "CVIP plays fee=1 song"); + CHECK(g_url_calls == 1, "CVIP song_url 正常请求 (calls=%d)", g_url_calls); + g_eof = 1; + wait_state(NCM_PC_STOPPED, 3000); + /* fee=8(免费听低音质)非会员也放行 */ + g_eof = 0; g_reads_left = -1; + g_cache_ok = 0; g_vip = NCM_VIP_NONE; g_fee = 8; g_url_calls = 0; + player_manager_play(2); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "fee=8 non-vip plays (免费听档放行)"); + CHECK(g_url_calls == 1, "fee=8 song_url requested (calls=%d)", g_url_calls); + g_fee = 0; g_eof = 1; + player_manager_set_quality(NCM_LEVEL_STANDARD, 10); + usleep(200 * 1000); player_manager_stop_thread(); printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n"); diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index ab57ee7..b325cbe 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -20,7 +20,7 @@ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ - app/logic/cover.c app/play/player_manager.c app/play/audio_out.c \ + app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \ app/play/ipc_media.c app/play/decoder.c app/play/minimp3_impl.c" echo "==> app+core (strict)" diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh index 1650f4b..cf7ad07 100755 --- a/patch_v2/ncm_app/build_ncm_host.sh +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -32,7 +32,7 @@ echo "==> host app sources (exclude app.c: has main + g_st; test provides them)" APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ - app/logic/cover.c app/play/player_manager.c app/play/audio_out.c \ + app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \ app/play/ipc_media.c app/play/decoder.c app/play/minimp3_impl.c" for f in $APP_SRC; do b=$(basename "$f" .c) diff --git a/patch_v2/ncm_app/core/ncm.h b/patch_v2/ncm_app/core/ncm.h index 611db29..f29af6d 100644 --- a/patch_v2/ncm_app/core/ncm.h +++ b/patch_v2/ncm_app/core/ncm.h @@ -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; /* 默认缓存目录(/../cache,即 MeowData/dev.player.ncm/cache)。 */ diff --git a/patch_v2/ncm_app/core/ncm_api.c b/patch_v2/ncm_app/core/ncm_api.c index 3002983..ddcd4e5 100644 --- a/patch_v2/ncm_app/core/ncm_api.c +++ b/patch_v2/ncm_app/core/ncm_api.c @@ -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]; diff --git a/patch_v2/ncm_app/core/ncm_api.h b/patch_v2/ncm_app/core/ncm_api.h index 6564585..658561f 100644 --- a/patch_v2/ncm_app/core/ncm_api.h +++ b/patch_v2/ncm_app/core/ncm_api.h @@ -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, diff --git a/patch_v2/ncm_app/core/ncm_common.h b/patch_v2/ncm_app/core/ncm_common.h index f69c579..f877de9 100644 --- a/patch_v2/ncm_app/core/ncm_common.h +++ b/patch_v2/ncm_app/core/ncm_common.h @@ -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) */ }; /* ---------- 常用尺寸 ---------- */ diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 0113f19..0110d5c 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index 54f916e..86bffe1 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ diff --git a/patch_v2/ncm_app/test/test_ncm_core.c b/patch_v2/ncm_app/test/test_ncm_core.c index a70a092..0402ae3 100644 --- a/patch_v2/ncm_app/test/test_ncm_core.c +++ b/patch_v2/ncm_app/test/test_ncm_core.c @@ -267,6 +267,85 @@ static void test_track_all_parse(void) { CHECK(songs[0].duration_ms == 253000 && songs[0].fee == 1, "song0 dt/fee"); } +static void test_vip_parse(void) { + printf("== vip/info 离线解析 + 档位表(§20)==\n"); + int64_t now = 1700000000000LL; /* 固定参考时刻(ms) */ + ncm_vip_info_t v; + + /* 1) v2 形态(data 子对象):associator.vipCode>0 + 未到期 → SVIP */ + const char *j1 = + "{\"code\":200,\"data\":{\"associator\":{\"vipCode\":100,\"isSign\":true," + "\"expireTime\":1700000100000},\"musicPackage\":{\"vipCode\":0,\"expireTime\":0}," + "\"redVipLevel\":0}}"; + CHECK(ncm_api_parse_vip_info(j1, strlen(j1), now, &v) == NCM_OK && + v.vip == NCM_VIP_SVIP && v.svip_expire_ms == 1700000100000LL, + "v2 associator -> SVIP (vip=%d exp=%lld)", v.vip, (long long)v.svip_expire_ms); + + /* 2) 音乐包(musicPackage.vipCode>0),无 associator → CVIP */ + const char *j2 = + "{\"code\":200,\"data\":{\"musicPackage\":{\"vipCode\":220,\"isSign\":false," + "\"expireTime\":1700000200000},\"redVipLevel\":0}}"; + CHECK(ncm_api_parse_vip_info(j2, strlen(j2), now, &v) == NCM_OK && + v.vip == NCM_VIP_CVIP && v.cvip_expire_ms == 1700000200000LL, + "v2 musicPackage -> CVIP (vip=%d)", v.vip); + + /* 3) 全零 → NONE */ + const char *j3 = + "{\"code\":200,\"data\":{\"associator\":{\"vipCode\":0,\"expireTime\":0}," + "\"musicPackage\":{\"vipCode\":0},\"redVipLevel\":0,\"redplusLevel\":0}}"; + CHECK(ncm_api_parse_vip_info(j3, strlen(j3), now, &v) == NCM_OK && v.vip == NCM_VIP_NONE, + "all-zero -> NONE (vip=%d)", v.vip); + + /* 4) 到期时间已过且 vipCode=0 → NONE(不复活) */ + const char *j4 = + "{\"code\":200,\"data\":{\"associator\":{\"vipCode\":0,\"isSign\":false," + "\"expireTime\":1699999900000}}}"; + CHECK(ncm_api_parse_vip_info(j4, strlen(j4), now, &v) == NCM_OK && v.vip == NCM_VIP_NONE, + "expired associator -> NONE (vip=%d)", v.vip); + + /* 5) 仅 expireTime > now(vipCode=0/isSign=false)→ 仍判 SVIP(BIZ_NOTES §1.2 字段判据) */ + const char *j5 = + "{\"code\":200,\"data\":{\"associator\":{\"vipCode\":0,\"isSign\":false," + "\"expireTime\":1700000300000}}}"; + CHECK(ncm_api_parse_vip_info(j5, strlen(j5), now, &v) == NCM_OK && v.vip == NCM_VIP_SVIP, + "expireTime>now alone -> SVIP (vip=%d)", v.vip); + + /* 6) v1 兜底形态(字段直接在根部):vipStatus=true → CVIP */ + const char *j6 = "{\"code\":200,\"vipStatus\":true,\"redVipLevel\":0}"; + CHECK(ncm_api_parse_vip_info(j6, strlen(j6), now, &v) == NCM_OK && v.vip == NCM_VIP_CVIP, + "v1 flat root vipStatus -> CVIP (vip=%d)", v.vip); + + /* 7) 顶层红胶等级 redVipLevel>0 → CVIP */ + const char *j7 = "{\"code\":200,\"data\":{\"redVipLevel\":5}}"; + CHECK(ncm_api_parse_vip_info(j7, strlen(j7), now, &v) == NCM_OK && v.vip == NCM_VIP_CVIP, + "redVipLevel>0 -> CVIP (vip=%d)", v.vip); + + /* 8) code != 200 → 错误(不判档) */ + const char *j8 = "{\"code\":301,\"msg\":\"需要登录\"}"; + CHECK(ncm_api_parse_vip_info(j8, strlen(j8), now, &v) == NCM_ERR_AUTH, + "code=301 -> AUTH err"); + + /* 9) 档位表:level 串 / 缓存键 / 门槛 */ + CHECK(strcmp(ncm_api_level_str(NCM_LEVEL_SURROUND), "sky") == 0 && + strcmp(ncm_api_level_str(NCM_LEVEL_SKY), "sky") == 0, + "SURROUND/SKY api str both 'sky'"); + CHECK(strcmp(ncm_api_level_key(NCM_LEVEL_SURROUND), "sky_c51") == 0 && + strcmp(ncm_api_level_key(NCM_LEVEL_SKY), "sky") == 0, + "cache keys unique: sky_c51 vs sky"); + CHECK(strcmp(ncm_api_level_str(NCM_LEVEL_JYEFFECT), "jyeffect") == 0 && + strcmp(ncm_api_level_str(NCM_LEVEL_JYMASTER), "jymaster") == 0 && + strcmp(ncm_api_level_str(NCM_LEVEL_DOLBY), "dolby") == 0, + "jyeffect/jymaster/dolby str (BIZ_NOTES §2.1)"); + CHECK(ncm_api_level_gate(NCM_LEVEL_STANDARD) == NCM_VIP_NONE && + ncm_api_level_gate(NCM_LEVEL_EXHIGH) == NCM_VIP_NONE && + ncm_api_level_gate(NCM_LEVEL_LOSSLESS) == NCM_VIP_CVIP && + ncm_api_level_gate(NCM_LEVEL_HIRES) == NCM_VIP_CVIP && + ncm_api_level_gate(NCM_LEVEL_JYEFFECT) == NCM_VIP_SVIP && + ncm_api_level_gate(NCM_LEVEL_SURROUND) == NCM_VIP_SVIP && + ncm_api_level_gate(NCM_LEVEL_DOLBY) == NCM_VIP_SVIP, + "level gates: free/CVIP/SVIP"); +} + int main(int argc, char **argv) { const char *data_dir = (argc > 1) ? argv[1] : "/tmp/ncm_test"; printf("ncm_core self-test, data_dir=%s\n", data_dir); @@ -282,6 +361,7 @@ int main(int argc, char **argv) { test_qr(); test_network(); test_track_all_parse(); + test_vip_parse(); printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS =="); return g_fail ? 1 : 0; diff --git a/patch_v2/ncm_app/test/test_ncm_core_host b/patch_v2/ncm_app/test/test_ncm_core_host index 86b76b4..6fe8755 100755 Binary files a/patch_v2/ncm_app/test/test_ncm_core_host and b/patch_v2/ncm_app/test/test_ncm_core_host differ