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
View File
@@ -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
+135
View File
@@ -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()<CVIP → `NCM_ERR_VIP`(新错误码 -15)直接返回,**零 url 请求**
(省一次请求,防风控);播放页显示「VIP 专享,开通会员后可播放」+ snack「VIP 专享歌曲」。
- fee=0/8 放行(8=免费听低音质,播放允许);已缓存的 VIP 曲本地可播不拦
(NCM_CACHE_DESIGN §2.4:下架/权限变化歌曲优先本地缓存)。
- fee 字段本就在 ncm_song_t 且 songmeta bin 原始镜像序列化,盘缓存 round-trip 无需改格式。
### 20.5 UI(设置页八档 + 默认档)
- 设置页音质 sheet 八档(高→低排列),每项「名称 (规格) · CVIP/SVIP(·未解锁)」;
未解锁档**允许选定**(播放时降级),行 sub 标「当前: xx (规格) · 需 CVIP/SVIP,将降级」。
⚠️ meow_sheet 无单项置灰能力(SDK 不在本批范围),置灰以「·未解锁」文字标注代替。
- **默认档 Hi-Res → 极高 HQ**(EXHIGH):仅新装/越界值生效,已持久化的合法旧值原样保留。
- 页脚改「无损/Hi-Res 需 CVIP ・ 臻音/母带需 SVIP,不支持时自动降级」。
- 档位显示名/规格收敛 chrome.c(`ncm_level_name/ncm_level_spec`,settings/player 共用)。
### 20.6 验证记录(0 真实 NCM 请求)
| 项 | 方式 | 结果 |
|---|---|---|
| vip 解析 9 例 | test_ncm_core(离线合成 JSON:v2 SVIP/CVIP/NONE/已过期/仅 expireTime 有效/v1 扁平 root/redVipLevel/code=301) | **全 PASS**(宿主 + qemu-mipsel) |
| 档位表 | level_str/key/gate 断言(sky vs sky_c51 唯一性) | **PASS** |
| 降级链全矩阵 | **bench/degrade_sim.c**(新,auto_f 注入假 try_fn):desired 9 × avail 9 + 全不可用 9 = **90 例全 PASS**(命中档=min(desired,avail)、请求数=逐档不差一次);钳制(DOLBY→SURROUND/负数→STANDARD)、未知 type 跳过、type 缺失按扩展名收 flac、下首恢复用户档、服务端静默降档采信 | **ALL PASS** |
| 播放状态机 | player_flow_sim **P1-P8**:P7=VIP 钳顶(非会员用户档 SURROUND→请求档 EXHIGH/缓存键仍 1000_sky_c51/eff 回填;SVIP 直达);P8=fee 门禁(fee=1 非会员 NCM_ERR_VIP 零请求/缓存命中绕过/CVIP 放行/fee=8 放行);P1-P6 零回归 | **ALL PASS** |
| 滑窗/分页/缓存/封面/audiod | window_sim / pagination_sim / cache_evict_sim / cover_chain_sim / audiod_chain_sim(重编新 appstate.c/audio_out.c) | **全 PASS**(14/14 等) |
| 解码回归 | test_decode flac 44.1k/16 + 96k/24 + mp3(本批未动 decoder/audio_out,确认零回归) | **3/3 PASS** |
| mipsel 构建 | build_ncm.sh 自有代码 -Wall -Wextra | **0 warning**;text 962908(+8.4KB vs §19) |
| 宿主离屏 | build_ncm_host.sh + 6 页 MEOW_UI_DUMP | 全 rc=0;设置页布局核验(新行 sub/页脚生效) |
### 20.7 md5(本批产物 + 改动文件)
```
9d31f2241a62f618d7625a24f9f7a3a7 ncm # mipsel 静态 App(新;未部署,留主会话)
8a0a4ee03509c29ad7eb0ec9f61cd9db ncm_host # 宿主离屏调试二进制
2c1c459dbae23872557378477880d7a3 core/ncm_api.c
3d5fb722d5fdf5d9ac1f61e18450b586 core/ncm_api.h
e87f71c90175b67307d92d83d57b8886 core/ncm_common.h # +NCM_ERR_VIP(-15)
8e0b34b05f0063a4653236577e05cd47 core/ncm.h # 默认档注释
8bb15b7d9bd98f583761dab2260b98cd app/logic/account.c # 新:会员态缓存+刷新策略
3909d070d0290730739456ef0ab1e153 app/logic/appstate.c # 默认 EXHIGH/account_load/登出清理/level_key
f080c9f4823a5b59410445b5ccc4f191 app/play/player_manager.c # 钳顶+fee 门禁+eff_level
7f94bfbd6ce739a8a52273316ca2eb9d app/ui/settings.c # 八档菜单
baa6d5530764258ae9f93b6e4150e06e app/ui/player.c # 生效档小字+VIP 专享文案
bf776093956102989a43dfc778efdd01 app/ui/chrome.c # ncm_level_name/spec
771f351d82dbea46cc1d61c4bfd3bd41 app/ui/login.c # 登录后 vip 刷新
de6adb873dc426e4973f12866df21eeb app/ui/home.c # home worker 超期/到期刷新
6b6816096d1c26773f88a882f30fa65a app/ncm_app_internal.h # g_st.vip*/eff_level/声明
9f8a812f48d673514ae4b7145a6689c7 test/test_ncm_core.c # vip 解析+档位表用例
a5e76601554c8981457ab3a1987dcee2 bench/degrade_sim.c # 新:降级链 90 例矩阵
27b01f4bfd6199b62f6490367b47bd4b bench/player_flow_sim.c # P7/P8 + stub 更新
8af2179da8709b2b525e9744a887de79 build_ncm.sh # +account.c
ac5b29c40488f34e9220b14105bf63c2 build_ncm_host.sh # +account.c
```
### 20.8 诚实出入 / 遗留
- **真机未部署未验**(留主会话统一)。真机验收点:①登录后/超 1 天/到期三条触发路径
各只发一次 vip/info(日志 `account: vip refresh (<why>) -> 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 不在范围):未解锁档以「·未解锁」标注代替置灰。
+129
View File
@@ -0,0 +1,129 @@
/* logic/account.c — 账号会员态缓存与刷新策略(§20,NCM_CACHE_DESIGN §2.1 定案)。
*
* 落盘:<data_dir>/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 <stdio.h>
#include <string.h>
#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);
}
+18 -5
View File
@@ -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) {
+17
View File
@@ -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):歌曲元数据本地缓存(<cache_dir>/meta/<id>.bin),
* 拉过的 song_detail 结果序列化落盘,再次入窗免重复请求(REPORT §17)。 */
+63 -15
View File
@@ -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 <stdio.h>
@@ -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_<id>_<level>.<ext>"。
* 同歌同档命中不重复下载;换档=新条目,旧档留给 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_<id>[.]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];
+34
View File
@@ -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";
}
}
+2
View File
@@ -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;
+1
View File
@@ -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;
+28 -3
View File
@@ -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;
}
+43 -22
View File
@@ -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);
+165
View File
@@ -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 <stdio.h>
#include <string.h>
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;
}
+88 -5
View File
@@ -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");
+1 -1
View File
@@ -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)"
+1 -1
View File
@@ -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)
+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) */
};
/* ---------- 常用尺寸 ---------- */
Binary file not shown.
Binary file not shown.
+80
View File
@@ -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;
Binary file not shown.