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

+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) {