Files
MeowOS-For-R3P2/patch_v2/ncm_app/bench/player_flow_sim.c
T
AskaEth de18cef02c feat(ncm): 缓存分目录(songs//covers/)+ 陈旧 mp3 缓存修复
任务1 缓存分目录(用户定案):\n- ncm_cache_init mkdir songs/ 与 covers/ 子目录 + 一次性迁移\n  (根混放 song_*/cov*/cover_current.jpg 挪进两子目录,幂等,legacy_migrated 计数)\n- ncm_cache_path 按 id 前缀路由(cov*→covers/,其余→songs/)\n- LRU(evict/total_bytes/7天 .part 回收)只扫 songs/;clear 清 songs/+covers/ 的 song_*\n- cover.c save_cover_raw 落 covers/cover_current.jpg\n\n任务2 陈旧 mp3 缓存修复(真机实锤:hires 档键下存 mp3,播放命中旧档):\n- song_local_file 缓存命中且用户档>EXHIGH(无损+)且实体是 .mp3 = 静默降级产物\n  → 迁移重命名到 exhigh 键 song_<id>_exhigh.mp3(选极高仍可用;目标已存在删旧)\n  → 本档视为未命中走下载(311 flac 门禁拿真无损/正确降级)\n\n验证:cache_evict_sim case10(目录迁移一次成型)+P1-9;player_flow_sim P13(迁移+miss)\n+P1-12 零回归;stream/window/search/degrade/cover_chain/test_ncm_core(host+qemu) 全 PASS;\nbuild_ncm.sh -Wall -Wextra 0 warning。0 真实请求。新 ncm md5 a90ba8c3 (text1028458/bss1364484)。
2026-09-02 13:51:36 +08:00

625 lines
33 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* bench/player_flow_sim.c — 播放线程状态机离线回归(0 真实请求,REPORT §18)。
*
* 驱动真实 app/play/player_manager.c(外层 player_thread + run_song 全链路),
* 解码器/音频输出/网络/缓存全部本地 stub(可注入:写失败、url 失败、慢下载)。
*
* 用例:
* P1 自然播完 → 自动顺延下一首(曲终不停在原地,err=0);
* P2 音频写失败(流死/audiod 断链)→ **置错停播,cur_song 不动**——§18 前静默 break
* 被外层当「自然播完」连环跳歌+狂发 song_url(本用例在旧代码上 FAIL);
* P3 song_url 无音源 → STOPPED + NCM_ERR_NOTFOUND + errmsg(UI 有错误可显示);
* P4 下载途中切歌 → 旧曲下载完成后**不得**把状态改回 PLAYING/写过期快照
* (still_current 护栏;旧代码在切歌后 ~下载完成点 会把 BUFFER 覆盖成 PLAYING);
* P5 错误后 play(cur) 重试 → 状态重新走 BUFFER→PLAYING,err 清零。
* P12 切音质后重播同一曲(§25):同 idx 在播时再 play —— 修前被解码主循环忽略
* (旧曲继续出声、state 恒 BUFFER/「缓冲中」,收尾被外层当播完顺延下一首);
* 修后 seq 变化即重启换档,状态回 PLAYING、新档位下载、eff_level 回填。
* P13 陈旧 hires.mp3(flac 门禁前静默降级产物,hires 键下存 mp3):song_local_file
* 迁移重命名到 exhigh 键(song_<id>_exhigh.mp3)+ 本档视为 miss 走下载(§NEW 任务2)。
*
* 编译(宿主,ncm_app 目录下):
* gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -Ivendor/jsmn -c bench/player_flow_sim.c -o /tmp/pf.o
* gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -Ivendor/jsmn -Ivendor/minimp3 -Ivendor/dr_flac -c app/play/player_manager.c -o /tmp/pf_pm.o
* gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -c app/play/stream_src.c -o /tmp/pf_ss.o
* gcc -O2 -Wl,--gc-sections -o player_flow_sim /tmp/pf.o /tmp/pf_pm.o /tmp/pf_ss.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 放行)。
* §21:边下边播——下载线程走 stub http_download(经真实 stream_src 预缓冲门槛),
* 解码器 stub 换 ncm_dec_open_src。
*/
#include "../app/ncm_app_internal.h"
#include "../app/play/decoder.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdarg.h>
#include <sys/stat.h>
#include <unistd.h>
ncm_app_state_t g_st;
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 };
volatile int g_app_quit = 0;
volatile int g_net_ready = 1;
int g_host_mock = 0;
/* ---- 公共 stub ---- */
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
{
size_t n = 0;
if (sz) { while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } dst[n] = 0; }
while (src[n]) n++;
return n;
}
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;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;
memset(out, 0, sizeof *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; }
/* §23:player_manager 改走 appstate_queue_*(队列来源)——sim 恒 PLAYLAST 队列 = 窗口 */
int appstate_queue_count(void) { return g_st.track_ids_count; }
int appstate_queue_song(int idx, ncm_song_t *out) { return appstate_song_copy(idx, out); }
/* ---- 缓存/网络 stub ----
* g_cache_ok=1 → lookup 命中(给真实存在的 /tmp 文件,免下载);
* 否则走下载:song_url(g_url_rc 可注入失败) → http_download(g_dl_delay_ms 模拟慢) → commit。 */
static int g_cache_ok = 1;
static char g_cache_real[600]; /* g_cache_ok==2:lookup 命中返回此真实路径(P13 陈旧 mp3 迁移用) */
static int g_url_rc = NCM_OK;
static int g_dl_delay_ms = 0;
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 注入的会员态 */
/* ---- 阶段3 续传/重试 stub 状态 ---- */
static uint64_t g_part_size; /* >0=模拟存在 .part 残体(续传起点) */
static int g_http_status; /* ncm_http_download_status 回填的响应状态(默认 200) */
static char g_last_range[64];/* 收到的 Range 头("bytes=...";无则空串) */
static int64_t g_resume_opens; /* ncm_cache_open_resume 调用数 */
static int64_t g_part_removes; /* ncm_cache_part_remove 调用数 */
static int g_writer_resume;/* 当前 writer 是否续传打开(模拟 .part 被删/保留) */
static int g_416_first; /* >0: 首个下载请求回 416(续传被拒),其后按 g_http_status */
static int64_t now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec*1000 + ts.tv_nsec/1000000; }
/* §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;
}
}
/* 兜底保证 /tmp/pf_sim.mp3 存在(fake 缓存命中用;§NEW 任务2 的迁移 rename 会把它挪走到
* exhigh 键——下次命中前补回,避免跨用例污染损坏共享命中文件)。 */
static void pf_ensure_sim_mp3(void)
{
struct stat st;
if (stat("/tmp/pf_sim.mp3", &st) == 0) return;
FILE *f = fopen("/tmp/pf_sim.mp3", "wb");
if (!f) return;
for (int i = 0; i < 4096; i++) fputc(i & 0xFF, f);
fclose(f);
}
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz)
{
(void)c; (void)id;
if (g_cache_ok == 2) { /* P13 真实命中模式:仅 mp3 命中(stale hires 实体是 mp3) */
if (ext && strcmp(ext, "mp3") == 0 && g_cache_real[0]) {
snprintf(out, out_sz, "%s", g_cache_real);
return NCM_OK;
}
return NCM_ERR_NOENT;
}
if (!g_cache_ok) return NCM_ERR_NOENT;
pf_ensure_sim_mp3();
snprintf(out, out_sz, "/tmp/pf_sim.mp3");
return NCM_OK;
}
static char g_tmp_path[] = "/tmp/pf_sim_dl.tmp"; /* 下载路径 stub 的 .tmp(§21:stream_src 要真实打开) */
static int g_commits; /* §21:commit 移到曲尾 dispose(边下边播),P7 等收尾用 */
static void pf_make_tmp(void) { FILE *f = fopen(g_tmp_path, "wb"); if (f) fclose(f); } /* stream_src 要真实打开 */
int ncm_cache_open(const ncm_cache_t *c, const char *id, const char *ext, ncm_cache_writer_t **w)
{
(void)c;
snprintf(g_last_key, sizeof g_last_key, "%s", id);
snprintf(g_last_ext, sizeof g_last_ext, "%s", ext ? ext : "");
pf_make_tmp();
g_writer_resume = 0;
*w = (ncm_cache_writer_t *)calloc(1, 64); return NCM_OK;
}
const char *ncm_cache_writer_tmp_path(const ncm_cache_writer_t *w) { (void)w; return g_tmp_path; }
int ncm_cache_write(ncm_cache_writer_t *w, const unsigned char *d, size_t l) { (void)w; (void)d; (void)l; return NCM_OK; }
int ncm_cache_commit(ncm_cache_writer_t *w) { free(w); g_cache_ok = 1; g_commits++; return NCM_OK; }
int ncm_cache_abort(ncm_cache_writer_t *w)
{
if (g_writer_resume) g_part_size = 0; /* 模拟:删除续传 writer 的 .part(续传失败→整首重下) */
g_writer_resume = 0; free(w); return NCM_OK;
}
int ncm_cache_abort_part(ncm_cache_writer_t *w) { g_writer_resume = 0; free(w); return NCM_OK; } /* 保留 .part */
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; }
/* ---- 阶段3 续传 stub ---- */
int ncm_cache_part_size(const ncm_cache_t *c, const char *id, const char *ext, uint64_t *sz)
{ (void)c; (void)id; (void)ext; if (!g_part_size) return NCM_ERR_NOENT; *sz = g_part_size; return NCM_OK; }
int ncm_cache_open_resume(const ncm_cache_t *c, const char *id, const char *ext, ncm_cache_writer_t **w)
{
(void)c;
snprintf(g_last_key, sizeof g_last_key, "%s", id);
snprintf(g_last_ext, sizeof g_last_ext, "%s", ext ? ext : "");
pf_make_tmp(); g_writer_resume = 1; g_resume_opens++;
*w = (ncm_cache_writer_t *)calloc(1, 64); return NCM_OK;
}
int ncm_cache_part_remove(const ncm_cache_t *c, const char *id, const char *ext)
{ (void)c; (void)id; (void)ext; g_part_removes++; g_part_size = 0; return NCM_OK; }
/* §NEW 任务5:song_local_file 旧键迁移调 ncm_cache_path(本 sim 走 stub 缓存);仅链接用。 */
int ncm_cache_path(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz)
{
const char *e = ext; if (e && e[0] == '.') e++;
snprintf(out, out_sz, "%s/song_%s.%s",
(c && c->dir[0]) ? c->dir : "/tmp", id ? id : "", e ? e : "bin");
return NCM_OK;
}
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;
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;
}
/* §20.3(HEAD 的降级链工作)把 player_manager 切到 ncm_api_song_url_auto_av;本 sim 只测播放流
* 不测档位链语义,故转发到上方 auto 桩(desired→level),0 请求。与本次两处 UI 竞态无关。 */
int ncm_api_song_url_auto_av(const char *device_id, const char *cookie, const char *id,
int account_vip, int fee, int desired,
ncm_play_url_t *out, int *out_level)
{
(void)account_vip; (void)fee;
return ncm_api_song_url_auto(device_id, cookie, id, desired, out, out_level);
}
static int g_dl_fail; /* 阶段3:>0 时下载返回 NCM_ERR_NET(模拟预缓冲前网络失败) */
int ncm_http_download_status(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t maxb, ncm_http_resp_t *resp, int *status_out)
{
(void)req; (void)maxb;
g_downloads++;
if (g_dl_delay_ms > 0) usleep((useconds_t)g_dl_delay_ms * 1000);
int st = (g_416_first && g_downloads == 1) ? 416 : (g_http_status ? g_http_status : 200);
if (status_out) *status_out = st; /* 状态感知:首 body 前写入(续传分支读它) */
g_last_range[0] = 0;
if (req && req->extra_hdrs) { /* 捕获 Range 头(续传断言) */
const char *r = strstr(req->extra_hdrs, "Range: bytes=");
if (r) {
r += strlen("Range: bytes=");
int i = 0;
while (r[i] && r[i] != '\r' && r[i] != '\n' && i < (int)sizeof g_last_range - 1) { g_last_range[i] = r[i]; i++; }
g_last_range[i] = 0;
}
}
if (st == 416) g_part_size = 0; /* 模拟:续传被拒后残体删除(重试走整首) */
memset(resp, 0, sizeof *resp);
resp->status = st;
if (g_dl_fail) return NCM_ERR_NET; /* 预缓冲前网络失败(无 body 投放) */
unsigned char chunk[65536];
memset(chunk, 0x5A, sizeof chunk);
if (st == 416) { /* 416:投一个 error body → cb 置 need_fresh 并 abort */
if (cb(ctx, chunk, 8) != 0) return NCM_ERR_FULL;
return NCM_ERR_HTTP;
}
for (int i = 0; i < 8; i++) /* 512KB 总分发(进度回调走 8 次) */
if (cb(ctx, chunk, sizeof chunk) != 0) return NCM_ERR_FULL;
return NCM_OK;
}
int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t maxb, ncm_http_resp_t *resp)
{ return ncm_http_download_status(req, cb, ctx, maxb, resp, NULL); }
/* ---- 解码器 stub(§19:player_manager 走 ncm_dec_* 分发层;§21:open 改 ncm_dec_open_src):
* g_eof 前恒有数据(P1 用 g_reads_left 控制播完点) ---- */
static int g_eof;
static int g_reads_left = -1; /* >=0: 剩余读次数后 EOF;-1: 直到 g_eof */
static int g_dec_open_bits = 16; /* 模拟流位宽(记录用) */
int ncm_dec_open_src(ncm_decoder_t **out, stream_src_t *src, int *rate, int *ch, int *bits,
int64_t *total_frames)
{
(void)src;
*out = (ncm_decoder_t *)calloc(1, 16);
if (rate) *rate = 44100;
if (ch) *ch = 2;
if (bits) *bits = g_dec_open_bits;
if (total_frames) *total_frames = 44100 * 180;
return *out ? 0 : -1;
}
int64_t ncm_dec_read(ncm_decoder_t *d, void *buf, int64_t cap_frames)
{
(void)d;
if (g_eof) return 0;
if (g_reads_left == 0) return 0;
if (g_reads_left > 0) g_reads_left--;
memset(buf, 0, (size_t)cap_frames * 2 * sizeof(int16_t));
return cap_frames;
}
int ncm_dec_seek_ms(ncm_decoder_t *d, int64_t ms, int64_t fb, int64_t dur)
{ (void)d; (void)ms; (void)fb; (void)dur; return 0; }
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/§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_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";
case NCM_LEVEL_HIGHER: return "higher";
default: return "standard";
}
}
/* ---- 音频输出 stub:g_write_fail 注入写失败 ---- */
static int g_write_fail;
static int g_writes;
static int g_opens; /* audio_out_open 次数(每首 run_song 开一次) */
int audio_out_open(int rate, int ch, int bits) { (void)rate; (void)ch; (void)bits; g_opens++; return 0; }
int audio_out_write(const void *s, int frames)
{ (void)s; g_writes++; return g_write_fail ? -1 : frames; }
int audio_out_pause(int paused) { (void)paused; return 0; }
void audio_out_close(void) { }
int audio_out_buffered_ms(void) { return 0; }
void audio_out_set_target(int ms) { (void)ms; }
/* ---- 歌词(未被调用的 worker 会被 gc-sections 丢弃;lyric_clear 由 worker 用,防御性提供) ---- */
void lyric_clear(void) { }
int lyric_parse(const char *a, const char *b) { (void)a; (void)b; return 0; }
static int g_fail;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
} while (0)
static int state(void) { return player_manager_state(); }
static int wait_state(int want, int ms)
{
for (int t = 0; t < ms; t += 10) { if (state() == want) return 1; usleep(10 * 1000); }
return 0;
}
static int wait_err(int want, int ms) /* 等错误码出现(初始态可能已是 STOPPED,等状态有竞态) */
{
for (int t = 0; t < ms; t += 10) { if (player_manager_error() == want) return 1; usleep(10 * 1000); }
return 0;
}
static int wait_downloads(int want, int ms)
{
for (int t = 0; t < ms; t += 10) { if (g_downloads >= want) return 1; usleep(10 * 1000); }
return 0;
}
int main(void)
{
/* 假 mp3 文件(cache 命中路径 stat 用) */
FILE *f = fopen("/tmp/pf_sim.mp3", "wb");
if (!f) { printf("FAIL tmp file\n"); return 1; }
for (int i = 0; i < 4096; i++) fputc(i & 0xFF, f);
fclose(f);
memset(&g_st, 0, sizeof g_st);
g_st.track_ids_count = 3;
for (int i = 0; i < 3; i++) g_st.song_ids[i] = 1000 + i;
g_st.level = NCM_LEVEL_STANDARD;
g_st.cache_limit_gb = 10;
if (player_manager_start() != 0) { printf("FAIL start\n"); return 1; }
printf("== P1: natural finish -> auto-advance, err=0 ==\n");
g_cache_ok = 1; g_reads_left = -1; g_eof = 0; g_write_fail = 0;
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 2000), "song0 playing (持续读流态可捕获)");
/* stub 解码/写瞬时完成,整列顺排在一次 poll 内跑完(cur 直接冲到 -1)——
* 改用 audio_out_open 计数判定:3 首全开过 = 自然顺延链走通。 */
g_reads_left = 1; /* song0 再读 1 次即 EOF → 自然播完 */
for (int t = 0; t < 4000 && state() != NCM_PC_STOPPED; t += 20) {
if (g_reads_left <= 0) g_reads_left = 1;
usleep(20 * 1000);
}
CHECK(g_opens == 3, "auto-advance chain: audio opens=%d (expect 3 首全开)", g_opens);
CHECK(state() == NCM_PC_STOPPED && player_manager_error() == 0,
"end of playlist -> STOPPED err=%d (expect 0)", player_manager_error());
printf("== P2: write fail (stream dead) -> STOPPED + err, cur unchanged (不连环跳歌) ==\n");
g_reads_left = -1; g_eof = 0; g_write_fail = 1; g_writes = 0;
player_manager_play(1);
CHECK(wait_err(NCM_ERR_IO, 2000), "err=NCM_ERR_IO after write fail (rc=%d)", player_manager_error());
CHECK(wait_state(NCM_PC_STOPPED, 1000), "stopped after write fail");
usleep(500 * 1000);
CHECK(player_manager_playing_song() == 1, "cur=%d (expect 1, 未跳歌)", player_manager_playing_song());
CHECK(state() == NCM_PC_STOPPED, "stays STOPPED (no cascade)");
CHECK(g_writes <= 2, "writes=%d (≤2: 第一次失败即停,不连环)", g_writes);
g_write_fail = 0;
printf("== P3: no stream url -> STOPPED + NOTFOUND + errmsg ==\n");
g_url_rc = NCM_ERR_NOTFOUND; g_cache_ok = 0;
player_manager_play(2);
CHECK(wait_err(NCM_ERR_NOTFOUND, 2000), "err=NOTFOUND on no-stream (rc=%d)", player_manager_error());
CHECK(wait_state(NCM_PC_STOPPED, 1000), "stopped on no-stream");
pthread_mutex_lock(&g_play.mu);
const char *em = g_play.errmsg;
int has_msg = em[0] != 0;
pthread_mutex_unlock(&g_play.mu);
CHECK(has_msg, "errmsg set (UI 可显示)");
g_url_rc = NCM_OK;
printf("== P4: switch during slow download -> no stale PLAYING overwrite ==\n");
g_cache_ok = 0; g_dl_delay_ms = 250; g_eof = 0; g_reads_left = -1;
player_manager_play(0);
CHECK(wait_downloads(1, 3000), "song0 download started");
player_manager_play(1); /* 旧曲下载途中切歌 */
/* 旧曲下载 +250ms 完成;新代码 still_current 护栏直接返回(状态保持 BUFFER),
* 旧代码会在此点把 state 覆盖成 PLAYING。+280ms 落在新曲下载窗口内(~+300 才开始)。 */
usleep(280 * 1000);
CHECK(state() == NCM_PC_BUFFER,
"after switch+during new download state=%d (expect BUFFER=%d; 旧代码被旧曲改成 PLAYING)",
state(), NCM_PC_BUFFER);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "new song eventually playing");
ncm_song_t snap;
CHECK(player_manager_current_song(&snap) == 0 && strcmp(snap.id, "1001") == 0,
"snapshot id=%s (expect 1001, 无过期快照)", snap.id);
g_eof = 1; /* 让新曲自然收尾 */
usleep(200 * 1000);
g_dl_delay_ms = 0;
printf("== P5: retry after error -> BUFFER->PLAYING, err cleared ==\n");
g_eof = 0; g_reads_left = -1; g_url_rc = NCM_ERR_NOTFOUND;
g_cache_ok = 0;
player_manager_play(2); /* 制造错误 */
CHECK(wait_err(NCM_ERR_NOTFOUND, 2000), "error state ready (rc=%d)", player_manager_error());
g_url_rc = NCM_OK; /* 网络恢复 */
player_manager_play(player_manager_playing_song()); /* 重试(UI 播放键路径) */
CHECK(wait_state(NCM_PC_PLAYING, 3000), "retry playing");
CHECK(player_manager_error() == 0, "err cleared on retry");
g_eof = 1;
printf("== P6: cache key = id+level, flac type -> .flac ext (§19) ==\n");
usleep(300 * 1000); /* 等 P5 曲收尾 */
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_dl_delay_ms = 0;
g_url_type = "flac";
player_manager_set_quality(NCM_LEVEL_LOSSLESS, 10); /* 换档 → g_level=lossless */
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "flac download song playing");
CHECK(strcmp(g_last_key, "1000_lossless") == 0,
"cache key=%s (expect 1000_lossless = id+档位)", g_last_key);
CHECK(strcmp(g_last_ext, "flac") == 0,
"cache ext=%s (expect flac, type=flac → .flac 落盘)", g_last_ext);
g_url_type = "mp3";
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。
* §21 时序:commit 移到 song0 曲尾 dispose(边下边播)——先 stop 阻断外层自动顺延
* 到 song1 的竞态,再等 commit 落地后才切缓存开关,否则 commit stub 会把
* g_cache_ok 重新置 1 / 自动顺延的 song1 会先发出请求 → 断言读数被污染。 */
g_eof = 1;
player_manager_stop();
{
int cb = g_commits;
for (int t = 0; t < 3000 && g_commits == cb; t += 10) usleep(10 * 1000);
}
g_eof = 0; g_reads_left = -1; /* 上一步非 VIP 部分设了 g_eof=1 让 song0 收尾;SVIP song1 需正常播 */
g_vip = NCM_VIP_SVIP; g_cache_ok = 0; g_url_calls = 0; g_url_req_level = -1;
g_url_type = "flac"; /* §2②:SURROUND 为无损级 → 应答 flac(真实),eff 回填 SURROUND */
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_url_type = "mp3";
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)。
* 注:把档位降回 EXHIGH——缓存 stub 的「命中」恒是 mp3;用户档若仍 SURROUND(>EXHIGH),
* 会被 §NEW 任务2 当作静默降级产物迁移/重下,与「绕过门禁」的本意不符(P13 单独测迁移)。 */
player_manager_set_quality(NCM_LEVEL_EXHIGH, 10);
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);
printf("== P9: .part 续传 -> Range 头 + 206 追加(阶段3 §5.2)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 200 * 1024; /* .part 残体 = 200KB(> 预缓冲门槛 128KB) */
g_http_status = 206; g_416_first = 0; g_dl_fail = 0; g_dl_delay_ms = 0;
g_resume_opens = 0; g_last_range[0] = 0; g_vip = NCM_VIP_NONE;
int cb0 = g_commits;
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 4000), "206 resume song playing");
for (int t = 0; t < 200 && !g_last_range[0]; t += 10) usleep(10 * 1000); /* 等 dl 线程写 Range */
CHECK(g_resume_opens >= 1, "resume opened (ncm_cache_open_resume=%lld)", (long long)g_resume_opens);
CHECK(strcmp(g_last_range, "204800-") == 0, "Range header=%s (expect 204800- = 200KB)", g_last_range);
g_eof = 1;
player_manager_stop(); /* 阻断顺延,等 commit 落盘 */
for (int t = 0; t < 3000 && g_commits == cb0; t += 10) usleep(10 * 1000);
CHECK(g_commits > cb0, "206 resume committed (commits %d->%d)", cb0, g_commits);
printf("== P10: 续传 416 -> 删 .part 整首重下(重试无 Range,阶段3 §5.2)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 64 * 1024; /* .part = 64KB(< 预缓冲门槛 → 触发 prebuffer 失败 → 重试) */
g_http_status = 200; g_416_first = 1; g_dl_fail = 0; g_dl_delay_ms = 0;
g_resume_opens = 0; g_downloads = 0; g_last_range[0] = 0; g_vip = NCM_VIP_NONE;
g_eof = 0; g_reads_left = -1;
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 5000), "416 -> fresh retry song playing");
CHECK(g_downloads == 2, "downloads=%d (expect 2: 续传 416 + 整首重下)", g_downloads);
CHECK(g_resume_opens == 1, "resume_opens=%lld (expect 1: 仅首试续传,重试整首)",
(long long)g_resume_opens);
CHECK(g_last_range[0] == 0, "retry no Range (残体已删,整首重下)");
g_eof = 1; player_manager_stop();
printf("== P11: 预缓冲前网络失败 -> 重试退避序列(阶段3 §4.1 风控)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_fail = 1; g_dl_delay_ms = 0;
g_downloads = 0; g_vip = NCM_VIP_NONE;
int64_t t0 = now_ms();
player_manager_play(0);
/* 首试瞬时失败(NCM_ERR_NET,无 body)→ 退避 800ms → 重试 1 次 → 仍失败 → set_err 停播 */
CHECK(wait_downloads(2, 4000), "retry happened (downloads=2)");
int64_t dt = now_ms() - t0;
CHECK(dt >= 700,
"backoff sequence: 2nd download at +%lldms (expect >= 700ms 退避,非连环轰炸)", (long long)dt);
CHECK(wait_state(NCM_PC_STOPPED, 4000), "retry exhausted -> STOPPED");
CHECK(player_manager_error() != 0, "err set after retry exhausted (rc=%d)", player_manager_error());
g_dl_fail = 0;
printf("== P12: 切音质后重播同一曲(同 idx 在播时再 play)-> 换档重启,非卡「缓冲中」(§25)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 1; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_delay_ms = 0; g_dl_fail = 0;
g_vip = NCM_VIP_NONE; g_writes = 0; g_downloads = 0; g_url_calls = 0; g_last_key[0] = 0;
/* 以 standard 档播放 song0(缓存命中,PLAYING) */
player_manager_set_quality(NCM_LEVEL_STANDARD, 10);
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "song0 playing at standard (cache hit)");
CHECK(g_writes > 0, "standard song actually emitting audio (writes=%d)", g_writes);
/* 切音质到 lossless,然后重播同一曲(同 idx=0 再次 play)。
* 新档位 = 新缓存条目(lookup 必 miss)→ 如实置 g_cache_ok=0 强制走下载(边下边播)。 */
player_manager_set_quality(NCM_LEVEL_LOSSLESS, 10);
g_cache_ok = 0;
player_manager_play(0);
/* §25 修前:同 idx 重播被解码主循环忽略(cur 不变、state 仍 BUFFER)→ 旧曲继续出声、
* 恒「缓冲中」,且旧曲收尾被外层当「自然播完」顺延下一首(新档位永不生效)。
* 修后:seq 变化即 break → 外层重跑 run_song(0) 换档下载 → 状态回到 PLAYING
* (UI 显示采样率/时间而非「缓冲中」)。 */
CHECK(wait_state(NCM_PC_PLAYING, 4000),
"re-tap same idx -> back to PLAYING (expect 换档开播,非卡 缓冲中)");
CHECK(g_downloads >= 1, "re-tap started new-quality download (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_lossless") == 0,
"re-tap cache key=%s (expect 1000_lossless = 新档位)", g_last_key);
/* sim 账号非会员 → lossless 被 §20 钳顶到 EXHIGH;关键是 eff_level 已按新意图回填
* (UI 据此显示实际生效档,不再停 BUFFER/「缓冲中」)。 */
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"re-tap eff_level=%d (expect EXHIGH=非会员钳顶的新档意图)", player_manager_eff_level());
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
printf("== P13: 陈旧 hires.mp3(静默降级产物)-> exhigh 迁移 + 本档 miss 重下(§NEW 任务2)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_delay_ms = 0; g_dl_fail = 0;
g_vip = NCM_VIP_NONE; g_writes = 0; g_downloads = 0; g_url_calls = 0;
g_last_key[0] = 0; g_last_ext[0] = 0; g_url_type = "mp3";
/* 用户档 = Hi-Res(>EXHIGH),缓存实体是一首 mp3 = flac 门禁前把 exhigh 的 mp3 写进 hires
* 键的「静默降级」产物。须被迁移到 exhigh 键(song_1000_exhigh.mp3),本档视为 miss 走下载。 */
unlink("/tmp/song_1000_exhigh.mp3"); /* 保证 exhigh 目标不存在(走 rename 而非删旧) */
FILE *hf = fopen("/tmp/pf_hires.mp3", "wb");
if (hf) { fputs("DEGRADED", hf); fclose(hf); }
snprintf(g_cache_real, sizeof g_cache_real, "/tmp/pf_hires.mp3");
g_cache_ok = 2; /* lookup 命中返回 g_cache_real(仅 mp3 键) */
player_manager_set_quality(NCM_LEVEL_HIRES, 10);
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "stale hires.mp3 redownloaded -> plays");
CHECK(g_downloads >= 1, "stale mp3 triggered redownload (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_hires") == 0,
"redownload cache key=%s (expect 1000_hires=用户档键)", g_last_key);
CHECK(strcmp(g_last_ext, "mp3") == 0, "redownload ext=%s (请求档为 mp3,维持原身份)", g_last_ext);
struct stat mm;
CHECK(stat("/tmp/song_1000_exhigh.mp3", &mm) == 0,
"exhigh migrated file exists (选「极高」档仍可用)");
CHECK(stat("/tmp/pf_hires.mp3", &mm) != 0, "stale hires.mp3 source gone (已迁走)");
g_cache_ok = 0; g_cache_real[0] = 0;
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
usleep(200 * 1000);
player_manager_stop_thread();
printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n");
return g_fail;
}