feat(ncm §24): 流媒体客户端边下边播阶段3——.part断点续传(Range/206/200/416)+LRU防淘汰(.tmp/.part跳过)+失败重试1次退避

按 devdocs/plan/NCM_STREAM_PLAY.md §8 阶段3:
- .part 断续传:中断留 .part,续传带 Range,206 追加 / 200 丢头段 / 416 删残留整首重下;URL 每次新拉
- LRU 防淘汰:evict/total_bytes 跳过 .tmp/.part(在播文件不被动过)
- 失败重试:预缓冲前网络失败重试 1 次,退避 800ms,重试拿新签名 URL(风控:0 真实请求,未部署)
- 顺手修:gb_to_bytes 返回 int 溢出致默认 10GB 上限失效 -> uint64_t
- 验证:stream_sim(续传 PCM 金标准+全矩阵)/player_flow_sim(P9/P10/P11)/cache_evict_sim(case7/8)/test_ncm_core 宿主+qemu-mipsel 全 PASS;build_ncm.sh 0 warning;ncm_host 离屏正常

md5 ncm f8f316a1e6f3bdd8f6b5921fdbb3f3a2
This commit is contained in:
MeowOS committed 2026-09-01 20:00:14 +08:00
1 parent fcff0b0ac1
commit 19e8fc93ed
14 files changed
+735 -93

No files matched your search

+90
View File
@@ -2228,3 +2228,93 @@ c9c115cda1340b92d1988e330eafbb5b bench/search_sim.c
- **`meow_textfield_activate` 为阻塞式模态 IME**(SDK 既定):进页自动弹键盘会阻塞 UI 直至用户关闭键盘,
与「点输入框唤 IME」一致(既有交互模式);宿主离屏以 `#ifndef MEOW_UI_HOST_TEST` 跳过以免挂起 dump。
- **未动 sdk/、meow_launcherd/、vendor/、core(除 cloudsearch 扩展)**;部署链路同 §13.5 留主会话。
---
## 24. 边下边播阶段 3:.part 断点续传 + LRU 防淘汰 + 失败重试 1 次(2026-09-01)
> 范围:仅 `patch_v2/ncm_app/`。按 devdocs/plan/NCM_STREAM_PLAY.md §8 阶段3 任务书实现
> (§5.1 LRU 防淘汰、§5.2 .part 续传、§4.1/§7 重试风控)。阶段 1+2 在 §21(commit `9e25db7`)。
> 风控纪律:真实 NCM 请求 **0 次**(全离线 stub/样本);**未部署真机**(留主会话统一)。
> 对外文档(APPS.md/README 等)称「流媒体客户端」,不写「网易云/NetEase」。
### 24.1 三件事
1. **.part 断点续传(§5.2)**:下载中断(切歌/断流/失败)后 `.tmp` 保留为 `song_<id>.<ext>.part`;
下次播放同曲走 `Range: bytes=<part_size>-` 一次续传:206=追加、200=服务器忽略 Range(body 头段与
.part 前缀重复,丢弃)、416/其它=放弃续传(删 .part)整首重下。URL 每次新拉(签名会过期,
风控红线「一首一 GET」仍满足)。
2. **LRU 防淘汰(§5.1)**:`ncm_cache_evict`/`ncm_cache_total_bytes` 扫描跳过 `.tmp`/`.part`
——写穿缓存态的 `.tmp`(边下边播全程都在播)与中断残体 `.part` 归写者管理域,不被缓存上限挤掉。
3. **下载失败重试(§4.1/§7)**:预缓冲(<128KB)前网络失败 → 整首重试 **1 次**(STREAM_RETRY_MAX=1),
重试前退避 **800ms**(STREAM_RETRY_BACKOFF_MS)防连环轰炸;重试每次重新 `song_url`(新签名)。
硬错误/切歌/退出不重试(安静放弃)。开播后失败不自动重连(播到截断点,§4.1)。
### 24.2 改动文件
- `core/ncm_cache.c/.h`:`is_partial_name`(evict/total 跳过 .tmp/.part);`ncm_cache_part_size`
/`ncm_cache_part_remove`/`ncm_cache_open_resume`(O_APPEND 追加 .part)/`ncm_cache_abort_part`
(.tmp→.part 保留);`ncm_cache_abort` 语义不变(封面/测试仍用)。
- `core/ncm_http.c/.h`:`ncm_http_download_status`(parse_headers 后、首 body 前把 `resp->status` 写入
`*status_out`,供续传回调预知 200/206/416)+ 放行 206。
- `app/play/stream_src.c/.h`:`stream_src_open_stream_at(out,path,written)`——续传时读端 written 置为
.part 前缀大小、read_pos=0,前缀秒读(F4/F5 语义复用)。
- `app/play/player_manager.c`:`song_src_t` 增续传字段(resume_base/first_body/st_ptr/need_fresh/skip);
`stream_dl_cb` 首 body 按 status 切写姿态;`dl_thread` 带 Range 头 + 状态感知下载;
`song_download_attempt`(单次下载尝试:.part 检测+续传/整首、预缓冲、失败分类)+ `song_local_file`
重试循环(≤1 次、退避 800ms);`song_src_dispose` 中断保留 .part、拒绝续传(need_fresh)删残体。
### 24.3 验证记录(0 真实 NCM 请求)
| 项 | 方式 | 结果 |
|---|---|---|
| .part 续传 PCM 金标准 | **stream_sim 增 resume 用例**(会话1 中断 40% 存 .part → 会话2 续传:读端从头跟播、commit 后完整==源)@10MB/s+@400kbps | 宿主 + qemu-mipsel **ALL PASS**:续传 PCM 与整首离线解码**逐字节一致**(1575936B)、commit==源(328320B)、`.part` size 正确(131328)、first_sound=0.00s(前缀秒开) |
| 边下边播全矩阵回归 | stream_sim 原 16 用例(normal/seek_dl/seek_wait/cut40/flaky × 3 速率 + flac_stream) | 宿主 + qemu 全 PASS(PCM 金标准不变) |
| LRU 防淘汰 | **cache_evict_sim 新 case7**(塞爆池 + 在播 `.tmp`/残体 `.part`)| PASS:`.tmp`/`.part` 存活、不计入 total、最旧正式文件被淘汰 |
| 续传 API 契约 | **cache_evict_sim 新 case8**(part_size/open_resume 追加/commit 转正/abort_part 保留/abort 删除/part_remove) | PASS(0 警告) |
| 续传控制流(Range/状态分支/重试) | **player_flow_sim 新 P9/P10/P11**:P9=Range 头 + 206 追加 commit;P10=416→删 .part 整首重下(重试无 Range);P11=预缓冲前网络失败→退避序列+重试 1 次后停播 | 宿主 + qemu **ALL PASS**(P1-P11;P11 实测 2nd download +862ms ≥700ms 退避) |
| core 回归 | build_core.sh → test_ncm_core_host + qemu test_ncm_core_mipsel | PASS(缓存命名/字典/会员态) |
| mipsel 构建 | build_ncm.sh 自有代码 -Wall -Wextra | **0 warning**;text 975234(+5.2KB vs §21 的 969996) |
| 宿主离屏 | build_ncm_host.sh + NCM_PAGE=home MEOW_UI_DUMP | rc=0,渲染正常(sdk meow_ui_vol.c 既有告警除外) |
### 24.4 本批顺手修(预存 bug,诚实记录)
- **`gb_to_bytes` 返回 `int` 溢出**:10/100GB(10×2^30=10737418240)超 int 上限 → 默认 10GB 上限实际
溢出成 0/巨大值,`ncm_cache_evict` 永不触发(缓存上限设置失效)。改 `uint64_t` 修真——这是阶段3
「被缓存上限挤掉在播文件」语义能成立的前提(上限真生效,靠 .tmp/.part 跳过实现防淘汰)。
cache_evict_sim case7/8 已复盖;test_ncm_core/cache_evict_sim 旧用例零回归。
- **player_flow_sim P7 SVIP 子用例遗漏 `g_eof=0`**(上一步设了 1 让 song0 收尾,SVIP song1 未重置 → 首帧
EOF、PLAYING 窗口过短)——本批修正(测试自身 bug,不影响生产代码)。
### 24.5 md5(本批产物)
```
f8f316a1e6f3bdd8f6b5921fdbb3f3a2 ncm # mipsel 静态 App(新;未部署,留主会话)
9efb76ff0ae6262eb25870549d0f06d3 ncm_host # 宿主离屏调试二进制
4b1045011c865bdfd88f447bb08007d9 core/ncm_cache.c # 阶段3:evict/total 跳 .tmp/.part + 续传 API
50c05a3e472c2309d1f164f9a5102cda core/ncm_cache.h
649efeddd2908574a6f51661f4abad4e core/ncm_http.c # ncm_http_download_status + 206 放行
87a89a0ddac2a41d96fd9fb150136c2d core/ncm_http.h
84042878770f47e47e0ee78e9e765bb0 app/play/stream_src.c # stream_src_open_stream_at(续传 written)
1430c2e1f7df36011dc562c3d636176f app/play/stream_src.h
d47b6f85c53f986bd7339f3f1df1fec5 app/play/player_manager.c # .part 检测+Range+206/200/416+重试 1 次
21904c4525b94776af02f7ecefde48e2 bench/stream_sim.c # resume 用例(中断→续传→PCM 金标准)
7ca024b7212aef43d3359cac2cbd014c bench/cache_evict_sim.c # case7/8:防淘汰 + 续传 API 契约
21348d4df94f7e88815423b32320b9e8 bench/player_flow_sim.c # P9/P10/P11:Range/416/重试退避
e767bb75b7f7ec096b9c9dc01791691d REPORT.md # 本批记录(§24)
(build_ncm.sh/build_ncm_host.sh 未改;app.json 未改)
```
### 24.6 诚实出入 / 遗留
- **真机未部署未验**(留主会话统一)。真机验收点:①断网一场中断 → 重播同曲秒开且续传(不重下前缀)
②播放中下载其他/封面触发 evict,在播首不中断 ③弱网预缓冲失败一次 → 自动整首重试(有 800ms 间隙,
不连环请求)④`.part` 残体在下次命中秒开(前缀已在盘)。
- `.part` 残体若长时间不续传(用户不再点该曲)会占磁盘:残体不进 LRU 淘汰(防在播误删),
v1 不做「孤儿 .part 定时清理」,留主会话决断(可加「清理缓存」时一并删)。
- 200(服务器忽略 Range)分支按「丢 body 头段」实现(与 .part 前缀重复字节丢弃)——因服务器返回全量
时 body[0..part_size] 与 .part 前缀逐字节一致(同URL同内容),续传不产生双份;416 分支静默降级
(播到已下前缀/删残体重下),未做「自动断点续传防抖重连」(风控,§5.2 明确不做)。
- 重试仅覆盖「预缓冲(<128KB)前网络失败」;开播后失败不重连(§4.1)。`STREAM_RETRY_BACKOFF_MS=800`
为固定值,未做指数退避多档(retry=1 时一档即可)。
- 未动 sdk/、meow_launcherd/、vendor/;部署链路同 §13.5 留主会话。
+178 -73
View File
@@ -158,9 +158,15 @@ void player_manager_stop(void)
/* ---- 每首歌的播放体(在外层循环内调用)---- */
/* 边下边播每首曲的源对象(§21):读端 stream_src + 下载路径写端/线程。
/* 边下边播每首曲的源对象(§21/阶段3):读端 stream_src + 下载路径写端/线程。
* run_song 栈上分配,下载线程 join 后才返回,生命周期安全。 */
#define PREBUFFER_BYTES (128 * 1024) /* 预缓冲门槛 = minimp3 MINIMP3_IO_SIZE(F3 实测强制) */
#define PREBUFFER_BYTES (128 * 1024) /* 预缓冲门槛 = minimp3 MINIMP3_IO_SIZE(F3 实测强制) */
#define STREAM_RETRY_MAX 1 /* 阶段3(§21.8/§4.1):首开声前失败重试上限(1 次,防风控) */
#define STREAM_RETRY_BACKOFF_MS 800 /* 重试退避:避免连环请求轰炸(真实 NCM 节制,§7 风控) */
/* song_download_attempt 返回哨兵(与 NCM_ERR_* 不冲突) */
#define DL_RETRYABLE (-1001) /* 预缓冲前网络失败:可整首重试 */
#define DL_ABORTED (-1002) /* 用户切歌/退出:静默放弃,不重试 */
typedef struct {
stream_src_t *src; /* 读端(下载路径 written 由下载线程推进;缓存命中=静态源) */
@@ -171,14 +177,37 @@ typedef struct {
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
size_t maxb; /* 下载限幅 */
int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */
/* 阶段3 .part 续传(§5.2) */
uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=<n>- */
int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */
int *st_ptr; /* 指向 dl_thread 的 status 局部变量(同线程,无竞态) */
int need_fresh; /* 416/其它:服务器拒绝续传 → 放 .part、交调用方整首重下 */
uint64_t skip; /* 200 忽略 Range:body 前 skip 字节与 .part 前缀重复,丢弃 */
} song_src_t;
/* http 体回调(下载线程上下文;ncm_http 每次投递 ≤4KB):write(2) 直写落盘 +
* 锁内推进 written + broadcast 唤醒读端 + 进度反馈。 */
* 锁内推进 written + broadcast 唤醒读端 + 进度反馈。
* 阶段3 续传(§5.2):首 body 字节按 response status 切换写姿态——
* 206(服务器支持 Range):正常追加到 .part 尾;
* 200(服务器忽略 Range):body 头 resume_base 字节与 .part 前缀重复 → skip 丢弃;
* 416/其它:放弃续传(need_fresh=1,abort),交调用方删残体整首重下。 */
static int stream_dl_cb(void *ctx, const unsigned char *data, size_t len)
{
song_src_t *ss = (song_src_t *)ctx;
if (stream_src_aborted(ss->src)) return 1; /* 切歌/停止:中止 http */
if (ss->first_body) {
ss->first_body = 0;
int st = ss->st_ptr ? *ss->st_ptr : 0;
if (ss->resume_base > 0 && st != 206 && st != 200) {
ss->need_fresh = 1; /* 416/其它:放弃续传 */
return 1; /* abort http(error body 不入盘) */
}
if (ss->resume_base > 0 && st == 200) ss->skip = ss->resume_base; /* 全量头段与 .part 重复 */
}
if (ss->skip) { /* 丢弃重复前缀字节 */
if (ss->skip >= len) { ss->skip -= len; return 0; }
data += (size_t)ss->skip; len -= (size_t)ss->skip; ss->skip = 0;
}
if (ncm_cache_write(ss->w, data, len) != NCM_OK) return 1;
stream_src_note_written(ss->src, (uint64_t)len);
pthread_mutex_lock(&g_play.mu);
@@ -194,14 +223,35 @@ static void *dl_thread(void *arg)
memset(&req, 0, sizeof req);
req.url = ss->url;
req.method = "GET";
req.extra_hdrs = "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
/* 续传:Range 头(§5.2)。URL 为本次新拉签名(不复用旧 URL,风控不增请求)。
* UA 现状保留;Connection: close 由 http 层补。 */
char hdrs[1200];
if (ss->resume_base > 0)
snprintf(hdrs, sizeof hdrs,
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36\r\n"
"Range: bytes=%llu-", (unsigned long long)ss->resume_base);
else
snprintf(hdrs, sizeof hdrs,
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
req.extra_hdrs = hdrs;
req.verify_ca = NCM_HTTP_VERIFY_DEFAULT;
req.timeout_ms = 15000; /* 绝对段(连接/握手/头)上限;body 空闲超时 15s(P2) */
/* 状态感知下载:首 body 前写入 *status(续传分支)。同线程,st_ptr 无竞态。 */
int status = 0;
ss->st_ptr = &status;
ss->first_body = 1;
ncm_http_resp_t resp;
int rc = ncm_http_download(&req, stream_dl_cb, ss, ss->maxb, &resp);
if (rc == NCM_OK && resp.status != 200) rc = NCM_ERR_HTTP;
int rc = ncm_http_download_status(&req, stream_dl_cb, ss, ss->maxb, &resp, &status);
ss->st_ptr = NULL;
if (rc == NCM_OK) {
/* 放行 200 与(续传时的)206;其余按 HTTP 错。续传遇 200=服务器忽略 Range(skip 已丢头段)。 */
int ok_st = (ss->resume_base > 0) ? (resp.status == 206 || resp.status == 200)
: (resp.status == 200);
if (!ok_st) rc = NCM_ERR_HTTP;
}
ss->dl_rc = rc;
/* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */
stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc);
@@ -209,7 +259,7 @@ static void *dl_thread(void *arg)
}
/* 收尾(播放线程,解码循环退出后调用):中止在途下载并 join → commit(rename 转正) 或
* abort(unlink .tmp)。阶段 2 语义:中断即丢弃;.part 续传/LRU 防淘汰留阶段 3。 */
* abort(阶段3:中断保留 .part 供下次续传;拒绝续传的 416 删残体)。 */
static void song_src_dispose(song_src_t *ss)
{
if (ss->dl_thr_on) {
@@ -222,8 +272,12 @@ static void song_src_dispose(song_src_t *ss)
/* 下载完整:commit 转正进 LRU 池(rename 不影响读端已开 fd,此处已近曲终) */
if (ncm_cache_commit(ss->w) == 0)
ncm_cache_evict(&g_st.ctx.cache); /* 写后检查:新落盘可能推过上限 */
} else {
} else if (ss->need_fresh) {
/* 服务器拒绝续传(416/其它):残体不可再续,删(下首整首重下) */
ncm_cache_abort(ss->w);
} else {
/* 中断(切歌/断流):保留 .part 残体,供下次播放 Range 续传(§5.2) */
ncm_cache_abort_part(ss->w);
}
ss->w = NULL;
}
@@ -248,6 +302,104 @@ static int still_current(int idx)
return ok;
}
/* 一次「取 URL → 开写端(命中 .part 则续传) → 起下载线程 → 等预缓冲 ≥128KB」尝试(阶段3)。
* 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试);
* DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。
* *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */
static int song_download_attempt(const ncm_song_t *s, int idx, const char *key, int want,
song_src_t *ss, char *path, size_t psz, int64_t *bytes,
int *real_err)
{
const char *id = s->id;
ss->resume_base = 0; ss->skip = 0; ss->need_fresh = 0; ss->first_body = 1; /* 每次尝试干净 */
/* 播放地址:用户档已在调用方按会员钳顶,此处逐档降级,接受 mp3 与 flac */
ncm_play_url_t 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;
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
const char *ext = "mp3";
if (ncm_streq(u.type, "flac")) ext = "flac";
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
/* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */
ncm_cache_evict(&g_st.ctx.cache);
/* 断点续传起点(§5.2):.part 残体存在且本次 URL 的 size > 残体大小 → 续传:
* 追加打开 .part(O_APPEND,保留已写字节),written 从 part_size 起算(读端秒开播)。 */
uint64_t part_size = 0;
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, key, ext, &part_size) == NCM_OK) &&
part_size > 0;
int resuming = 0;
if (have_part && (long)part_size < u.size) {
rc = ncm_cache_open_resume(&g_st.ctx.cache, key, ext, &ss->w);
if (rc == NCM_OK) { ss->resume_base = part_size; resuming = 1; }
else have_part = 0; /* 残体打开失败:转整首 */
}
if (!resuming) {
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, key, ext); /* 残体过期(size 不符):删 */
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->w);
if (rc != NCM_OK) return rc;
}
if (stream_src_open_stream_at(&ss->src, ncm_cache_writer_tmp_path(ss->w),
resuming ? part_size : 0) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
return NCM_ERR_IO;
}
ncm_strlcpy(path, ncm_cache_writer_tmp_path(ss->w), psz); /* 日志用 */
ss->total = (u.size > 0) ? u.size : 0;
/* 上限:Hi-Res flac 单曲可达数百 MB,flac 上限 1GB;mp3 维持 64MB。
* size 可信时按 size 限幅(防超额),未知按格式默认上限(续传 body ≤ size 亦安全)。 */
size_t cap = (ext[0] == 'f') ? (1024u << 20) : (64u << 20);
ss->maxb = (u.size > 0 && (uint64_t)u.size < cap) ? (size_t)u.size : cap;
ncm_strlcpy(ss->url, u.url, sizeof ss->url);
if (pthread_create(&ss->dl_thr, NULL, dl_thread, ss) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return NCM_ERR_IO;
}
ss->dl_thr_on = 1;
/* 预缓冲:≥128KB(minimp3 强制门槛,F3)才放行 open_cb;final/failed 提前出。
* still_current 护栏覆盖本阻塞点:等待期间切歌/停止 → 保留 .part 静默放弃。 */
for (;;) {
int wr = stream_src_wait(ss->src, PREBUFFER_BYTES, 100);
if (wr != 0) break;
if (g_app_quit || !still_current(idx)) {
stream_src_abort(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort_part(ss->w); ss->w = NULL; /* 切歌/退出:保留残体供下次续传 */
stream_src_close(ss->src); ss->src = NULL;
return DL_ABORTED; /* 调用方 still_current 复核后静默返回 */
}
}
if (stream_src_aborted(ss->src)) {
int frc = stream_src_failed(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort_part(ss->w); ss->w = NULL; /* 中止:保留残体 */
stream_src_close(ss->src); ss->src = NULL;
return frc ? frc : NCM_ERR_NET;
}
if (stream_src_failed(ss->src) && stream_src_written(ss->src) < PREBUFFER_BYTES) {
int frc = stream_src_failed(ss->src);
if (real_err) *real_err = frc;
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
/* 预缓冲前网络失败:删残体(不保留 .part),交调用方整首重试(含续传失败重下)。
* 已下过门槛的断流照常放行——读端播到截断点自然结束(F5)。 */
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return DL_RETRYABLE;
}
/* final < 门槛(小文件/截断):照样放行,open_cb 读到的就是全部 */
*bytes = ss->total > 0 ? ss->total : (int64_t)stream_src_written(ss->src);
return NCM_OK;
}
/* 得到本地音频源(§21 边下边播)。命中缓存 → 静态 stream_src(final=size,read 不阻塞,
* 顺带修 P1:不再 mmap 整文件);否则 URL 降级 → 起下载线程写 .tmp → 预缓冲 ≥128KB 即返回
* (下载在后台继续,解码经 stream_src read_cb 跟播)。
@@ -302,75 +454,28 @@ static int song_local_file(const ncm_song_t *s, int idx, song_src_t *ss,
if ((s->fee == 1 || s->fee == 4) && account_vip() < NCM_VIP_CVIP)
return NCM_ERR_VIP;
/* 播放地址:用户档先按会员权限钳顶,再逐档降级(§20 全档表),接受 mp3 与 flac */
/* 播放下载(阶段3 重试策略 §4.1,风控 §7:真实 NCM 请求节制):用户档先按会员权限
* 钳顶,再逐档降级(§20)。预缓冲前网络失败重试 STREAM_RETRY_MAX 次(退避
* STREAM_RETRY_BACKOFF_MS 防连环轰炸),重试每次重新 song_url(新签名 URL,eapi 便宜);
* 硬错误/切歌/退出不重试。返回值:NCM_OK=src 就绪;DL_ABORTED=切歌/退出;其它负=错误。 */
int want = g_level;
int vip_cap = account_level_cap();
if (want > vip_cap) want = vip_cap;
ncm_play_url_t 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;
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
const char *ext = "mp3";
if (ncm_streq(u.type, "flac")) ext = "flac";
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
/* 写前检查:池大小超限先按 LRU 淘汰(此线程尚未开 temp,无在途文件被误删风险) */
ncm_cache_evict(&g_st.ctx.cache);
/* 边下边播(§21):下载线程写 .tmp(write(2) 直写),解码线程 pread 同一文件 */
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->w);
if (rc != NCM_OK) return rc;
if (stream_src_open_stream(&ss->src, ncm_cache_writer_tmp_path(ss->w)) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
return NCM_ERR_IO;
}
ncm_strlcpy(path, ncm_cache_writer_tmp_path(ss->w), psz); /* 日志用 */
ss->total = (u.size > 0) ? u.size : 0;
/* 上限:Hi-Res flac 单曲可达数百 MB(192k/24bit 5min ≈345MB),flac 上限 1GB;mp3 维持 64MB。
* size 可信时按 size 限幅(防超额),未知按格式默认上限。 */
size_t cap = (ext[0] == 'f') ? (1024u << 20) : (64u << 20);
ss->maxb = (u.size > 0 && (uint64_t)u.size < cap) ? (size_t)u.size : cap;
ncm_strlcpy(ss->url, u.url, sizeof ss->url);
if (pthread_create(&ss->dl_thr, NULL, dl_thread, ss) != 0) {
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return NCM_ERR_IO;
}
ss->dl_thr_on = 1;
/* 预缓冲:≥128KB(minimp3 强制门槛,F3)才放行 open_cb;final/failed 提前出。
* still_current 护栏覆盖本阻塞点:等待期间切歌/停止 → 中止下载静默放弃。 */
for (;;) {
int wr = stream_src_wait(ss->src, PREBUFFER_BYTES, 100);
if (wr != 0) break;
if (g_app_quit || !still_current(idx)) {
stream_src_abort(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return NCM_ERR_IO; /* 调用方 still_current 复核后静默返回 */
int last_rc = NCM_ERR_NET;
for (int attempt = 0; attempt <= STREAM_RETRY_MAX; attempt++) {
if (attempt > 0) {
meow_log(MEOW_LOG_WARN, "play: download retry %d/%d id=%s backoff=%dms",
attempt, STREAM_RETRY_MAX, id, STREAM_RETRY_BACKOFF_MS);
usleep(STREAM_RETRY_BACKOFF_MS * 1000); /* 退避:不轰炸 */
if (g_app_quit || !still_current(idx)) return NCM_ERR_IO;
}
int real_err = NCM_ERR_NET;
int rc = song_download_attempt(s, idx, key, want, ss, path, psz, bytes, &real_err);
if (rc == NCM_OK) return NCM_OK;
if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */
return rc; /* DL_ABORTED / 硬错误:不重试 */
}
if (stream_src_aborted(ss->src) ||
(stream_src_failed(ss->src) && stream_src_written(ss->src) < PREBUFFER_BYTES)) {
/* 中止,或首开声前失败(written < 预缓冲门槛):abort .tmp
*(阶段 2 不保留不重试;重试策略留阶段 3)。已下过门槛的断流照常放行——
* 读端播到截断点自然结束(F5)。 */
int frc = stream_src_failed(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort(ss->w); ss->w = NULL;
stream_src_close(ss->src); ss->src = NULL;
return frc ? frc : NCM_ERR_NET;
}
/* final < 门槛(小文件/截断):照样放行,open_cb 读到的就是全部 */
*bytes = ss->total > 0 ? ss->total : (int64_t)stream_src_written(ss->src);
return NCM_OK;
return last_rc; /* 重试耗尽:最后一次网络错误 */
}
static void set_err(int err, const char *msg)
+10
View File
@@ -64,6 +64,16 @@ int stream_src_open_stream(stream_src_t **out, const char *tmp_path)
return src_alloc(out, fd); /* written=0/final=0:下载中 */
}
int stream_src_open_stream_at(stream_src_t **out, const char *path, uint64_t written)
{
if (!out || !path) return -1;
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
int rc = src_alloc(out, fd);
if (rc == 0) (*out)->written = written; /* 续传(§5.2):前缀已落盘,读端 read_pos=0 可即读 */
return rc;
}
int stream_src_open_static(stream_src_t **out, const char *path, uint64_t size)
{
if (!out || !path) return -1;
+3
View File
@@ -30,6 +30,9 @@ enum { STREAM_SRC_SEEK_SET = 0, STREAM_SRC_SEEK_CUR = 1, STREAM_SRC_SEEK_END = 2
/* 下载路径:tmp_path 已由 ncm_cache_open 创建(写端持有自己的写 fd);
* 本函数只开只读 fd(pread 与写端 write 无偏移冲突),written 从 0 起由写端推进。 */
int stream_src_open_stream(stream_src_t **out, const char *tmp_path);
/* 断点续传(阶段3,§5.2):path 已含既存前缀(.part),written 置为前缀字节数——
* 读端 read_pos=0 可立即读到已下前缀(秒开播),写端从 written 继续推进。 */
int stream_src_open_stream_at(stream_src_t **out, const char *path, uint64_t written);
/* 缓存命中路径:完整文件,written=final=size,read 永不阻塞(同一套读回调)。 */
int stream_src_open_static(stream_src_t **out, const char *path, uint64_t size);
void stream_src_close(stream_src_t *s);
+90
View File
@@ -195,6 +195,96 @@ int main(void)
CHECK(ncm_cache_total_bytes(&g_c) == 0, "total=%llu after clear (expect 0)",
(unsigned long long)ncm_cache_total_bytes(&g_c));
/* ⑦ 阶段3:播放中的 .tmp / 中断残体 .part 不被 LRU 淘汰,且不计入池占用。
* 「正在下载播放的文件」= .tmp(写穿缓存态);「上次中断的可续传残体」= .part。
* 二者在 evict/total_bytes 中被跳过——防正在听的歌被缓存上限挤掉(§5.1)。 */
printf("== case 7: evict skips .tmp/.part (playing file not evicted; stage3 §5.1) ==\n");
cleanup();
ncm_cache_set_limit_gb(&g_c, 1); /* 1GB(int 内,避免既有 gb_to_bytes int 溢出 bug 干扰本用例) */
make_cached("a", ".mp3", mb(600), 1000); /* 600MB 最旧 */
make_cached("b", ".mp3", mb(600), 1100); /* 600MB */
/* 在途 .tmp(边下边播写穿缓存态):song_b.mp3.tmp */
{
char p[600];
ncm_cache_path(&g_c, "b", ".mp3", p, sizeof p);
strncat(p, ".tmp", sizeof p - strlen(p) - 1);
int fd = open(p, O_WRONLY | O_CREAT, 0644);
if (ftruncate(fd, (off_t)mb(300)) != 0) printf(" truncate tmp err\n");
close(fd);
}
/* 中断残体 .part(可续传):song_d.mp3.part */
{
char p[600];
ncm_cache_path(&g_c, "d", ".mp3", p, sizeof p);
strncat(p, ".part", sizeof p - strlen(p) - 1);
int fd = open(p, O_WRONLY | O_CREAT, 0644);
if (ftruncate(fd, (off_t)mb(300)) != 0) printf(" truncate part err\n");
close(fd);
}
CHECK(ncm_cache_total_bytes(&g_c) == mb(1200),
"total excludes .tmp/.part (=%lluMB, expect 1200MB)",
(unsigned long long)(ncm_cache_total_bytes(&g_c) / (1024*1024)));
ncm_cache_evict(&g_c);
CHECK(!cached_exists("a", ".mp3"), "oldest committed a evicted");
CHECK(cached_exists("b", ".mp3"), "b kept (<=limit)");
/* .tmp/.part 文件仍在(未被打包进 LRU 淘汰) */
{
char tp[600], pp[600];
ncm_cache_path(&g_c, "b", ".mp3", tp, sizeof tp); strncat(tp, ".tmp", sizeof tp-strlen(tp)-1);
ncm_cache_path(&g_c, "d", ".mp3", pp, sizeof pp); strncat(pp, ".part", sizeof pp-strlen(pp)-1);
struct stat st;
CHECK(stat(tp, &st) == 0, "playing .tmp survives evict");
CHECK(stat(pp, &st) == 0, "resume .part survives evict");
}
CHECK(ncm_cache_total_bytes(&g_c) == mb(600),
"total after evict excludes partials (=%lluMB, expect 600MB)",
(unsigned long long)(ncm_cache_total_bytes(&g_c) / (1024*1024)));
/* ⑧ 阶段3:部分文件 API 快检——part_size 检测 / open_resume 追加 / abort_part 保留 /
* abort 删除 的命名契约(供 player_manager 续传链路)。 */
printf("== case 8: .part resume API contract (stage3 §5.2) ==\n");
cleanup();
{
/* 用 ncm_cache_open_resume 打开上一步遗留 .part 无(已 cleanup)——先造一个真 .part */
char p[600];
ncm_cache_path(&g_c, "r", ".mp3", p, sizeof p);
snprintf(p + strlen(p), sizeof p - strlen(p), ".part");
int fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "ABCDEF", 6); close(fd); }
uint64_t sz = 0;
CHECK(ncm_cache_part_size(&g_c, "r", ".mp3", &sz) == NCM_OK && sz == 6,
"part_size reports 6 (got %llu)", (unsigned long long)sz);
ncm_cache_writer_t *w = NULL;
CHECK(ncm_cache_open_resume(&g_c, "r", ".mp3", &w) == NCM_OK, "open_resume ok");
CHECK(ncm_cache_write(w, (const unsigned char *)"GH", 2) == NCM_OK, "resume append write");
CHECK(ncm_cache_commit(w) == NCM_OK, "resume commit (rename .part->final)");
/* commit 后 .part 转正:lookup 命中且内容=ABCDEFGH,.part 消失 */
char path[600];
CHECK(ncm_cache_lookup(&g_c, "r", ".mp3", path, sizeof path) == NCM_OK, "resume commit lookup hit");
FILE *f = fopen(path, "rb");
char buf[16] = {0}; int n = 0;
if (f) { n = (int)fread(buf, 1, sizeof buf - 1, f); fclose(f); }
CHECK(n == 8 && memcmp(buf, "ABCDEFGH", 8) == 0, "committed bytes=%.8s (expect ABCDEFGH)", buf);
CHECK(ncm_cache_part_size(&g_c, "r", ".mp3", &sz) == NCM_ERR_NOENT, ".part gone after commit");
/* abort_part 保留残体 / abort 删除:造两个 .part 分别走两路 */
ncm_cache_path(&g_c, "s", ".mp3", p, sizeof p);
snprintf(p + strlen(p), sizeof p - strlen(p), ".part");
fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "1234", 4); close(fd); }
CHECK(ncm_cache_open_resume(&g_c, "s", ".mp3", &w) == NCM_OK, "s open_resume");
CHECK(ncm_cache_abort_part(w) == NCM_OK, "s abort_part");
struct stat st2;
CHECK(stat(p, &st2) == 0, "abort_part keeps .part (resumable)");
CHECK(ncm_cache_open_resume(&g_c, "s", ".mp3", &w) == NCM_OK, "s reopen_resume");
CHECK(ncm_cache_abort(w) == NCM_OK, "s abort (delete)");
CHECK(stat(p, &st2) != 0, "abort deletes .part");
/* part_remove 显式删除 */
fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "x", 1); close(fd); }
CHECK(ncm_cache_part_remove(&g_c, "s", ".mp3") == NCM_OK, "part_remove ok");
CHECK(stat(p, &st2) != 0, "part_remove deleted .part");
}
cleanup();
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
return g_fail ? 1 : 0;
+112 -10
View File
@@ -66,6 +66,9 @@ int appstate_song_copy(int gidx, ncm_song_t *out)
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 文件,免下载);
@@ -80,6 +83,15 @@ 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; }
@@ -100,22 +112,41 @@ int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char
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 : "");
FILE *f = fopen(g_tmp_path, "wb"); /* 建空 .tmp:stream_src_open_stream 需存在 */
if (f) fclose(f);
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; }
static int g_commits; /* §21:commit 移到曲尾 dispose(边下边播),P7 等收尾用 */
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) { free(w); 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; }
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)
@@ -133,20 +164,42 @@ int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int l
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)
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);
for (int i = 0; i < 8; i++) /* 512KB 总分发(进度回调会走 8 次) */
if (cb(ctx, chunk, sizeof chunk) != 0) break;
memset(resp, 0, sizeof *resp);
resp->status = 200;
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 控制播完点) ---- */
@@ -366,6 +419,7 @@ int main(void)
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;
player_manager_play(1);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "svip song playing");
@@ -406,6 +460,54 @@ int main(void)
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;
usleep(200 * 1000);
player_manager_stop_thread();
printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n");
+122 -2
View File
@@ -116,6 +116,7 @@ typedef struct {
int jitter_ms; /* 每块延迟 ±jitter */
double cut_frac; /* >0: 写到该比例即断流(finish 带错误) */
int flaky; /* 断续网:每 ~6 块额外停 100-400ms */
uint64_t start; /* 起点偏移(续传:从 .part 大小续写;0=从头) */
stream_src_t *src;
ncm_cache_writer_t *w;
int rc; /* NCM_OK / 负 */
@@ -125,7 +126,7 @@ static void *net_thread(void *arg)
{
net_t *nt = (net_t *)arg;
const size_t CH = 4096;
size_t off = 0;
size_t off = (size_t)nt->start;
size_t cut_at = (nt->cut_frac > 0) ? (size_t)(nt->size * nt->cut_frac) : (size_t)-1;
unsigned rng = 12345;
int chunk_no = 0;
@@ -211,7 +212,7 @@ static int run_case(const char *tag, const unsigned char *data, size_t size,
printf(" FAIL stream open\n"); return -1;
}
net_t nt = { data, size, rate_Bps, jitter_ms, cut_frac, flaky, src, w, NCM_OK };
net_t nt = { data, size, rate_Bps, jitter_ms, cut_frac, flaky, 0, src, w, NCM_OK };
pthread_t nth;
double t0 = now_s();
if (pthread_create(&nth, NULL, net_thread, &nt) != 0) { printf(" FAIL pthread\n"); return -1; }
@@ -312,6 +313,119 @@ static int run_case(const char *tag, const unsigned char *data, size_t size,
return 0;
}
/* 续传用例(阶段3 §5.2):会话1 中断下载存 .part,会话2 从 .part 续传 → 完整 PCM 金标准比对。
* 不含 http/Range(那是 player_flow_sim 的职责)——本用例验证「.part 前缀已在盘上、续写不被重复、
* 读端从头跟播、commit 后完整等于源文件」的写穿缓存续传契约。 */
static int run_resume_case(const char *tag, const unsigned char *data, size_t size,
uint64_t rate_Bps, int jitter_ms, int flaky, double resume_frac,
const pcm_buf_t *golden)
{
case_result_t rr;
memset(&rr, 0, sizeof rr);
char dir[] = "/tmp/ncm_stream_resume_XXXXXX";
if (!mkdtemp(dir)) { printf(" FAIL mkdtemp\n"); return -1; }
char cdir[600];
snprintf(cdir, sizeof cdir, "%s/cache", dir);
ncm_cache_t cache;
ncm_cache_init(&cache, cdir);
char key[32];
static int rcase_no = 0;
snprintf(key, sizeof key, "rs%d", rcase_no++);
/* ---- 会话1:中断下载,写 resume_frac 比例 → abort_part 存 .part ---- */
ncm_cache_writer_t *w1 = NULL;
if (ncm_cache_open(&cache, key, "mp3", &w1) != NCM_OK) { printf(" FAIL s1 open\n"); return -1; }
stream_src_t *src1 = NULL;
if (stream_src_open_stream(&src1, ncm_cache_writer_tmp_path(w1)) != 0) { printf(" FAIL s1 stream\n"); return -1; }
net_t nt1 = { data, size, rate_Bps, jitter_ms, resume_frac, flaky, 0, src1, w1, NCM_OK };
pthread_t n1;
pthread_create(&n1, NULL, net_thread, &nt1);
pthread_join(n1, NULL);
stream_src_close(src1);
ncm_cache_abort_part(w1); /* .tmp → .part(保留前缀字节) */
uint64_t part_size = 0;
if (ncm_cache_part_size(&cache, key, "mp3", &part_size) != NCM_OK || part_size == 0) {
printf(" FAIL .part not parked\n"); return -1;
}
uint64_t exp_part = (uint64_t)((double)size * resume_frac);
CHECK(part_size == exp_part, ".part size=%llu (expect %llu 前缀)",
(unsigned long long)part_size, (unsigned long long)exp_part);
/* ---- 会话2:续传(.part 追加)+ 读端从头跟播 → 完整 PCM ---- */
ncm_cache_writer_t *w2 = NULL;
if (ncm_cache_open_resume(&cache, key, "mp3", &w2) != NCM_OK) { printf(" FAIL s2 resume open\n"); return -1; }
stream_src_t *src2 = NULL;
if (stream_src_open_stream_at(&src2, ncm_cache_writer_tmp_path(w2), part_size) != 0) {
printf(" FAIL s2 stream\n"); return -1;
}
net_t nt2 = { data, size, rate_Bps, jitter_ms, 0, flaky, part_size, src2, w2, NCM_OK };
pthread_t n2;
double t0 = now_s();
pthread_create(&n2, NULL, net_thread, &nt2);
/* 预缓冲(前缀 ≥128KB 则立即满足;不足则等续写下足) */
double wd_t0 = now_s();
int wr;
while ((wr = stream_src_wait(src2, 128 * 1024, 100)) == 0)
if (now_s() - wd_t0 > 120) { printf(" FAIL resume prebuffer watchdog\n"); g_fail = 1; _exit(1); }
ncm_decoder_t *dec = NULL;
int rate = 0, ch = 0, bits = 0;
if (ncm_dec_open_src(&dec, src2, &rate, &ch, &bits, NULL) != 0) {
printf(" FAIL resume dec open\n"); return -1;
}
if (audio_out_open(rate, ch, bits) != 0) { printf(" FAIL audio open\n"); g_fail = 1; _exit(1); }
static unsigned char pcm[4096 * 2 * 4];
int64_t decoded = 0;
for (;;) {
if (now_s() - t0 > 150) { printf(" FAIL resume watchdog\n"); g_fail = 1; _exit(1); }
int64_t fr = ncm_dec_read(dec, pcm, 4096);
if (fr == 0) break;
if (rr.first_sound_s < 0) rr.first_sound_s = now_s() - t0;
if (audio_out_write(pcm, (int)fr) != (int)fr) { printf(" FAIL audio write\n"); g_fail = 1; _exit(1); }
pcm_put(&rr.pcm, pcm, (size_t)fr * (size_t)ch * (size_t)bits / 8);
decoded += fr;
if (rate > 0) usleep((useconds_t)(fr * 1000000 / rate));
}
rr.frames = decoded;
rr.wall_s = now_s() - t0;
stream_src_stats(src2, &rr.underruns, &rr.wait_ms);
audio_out_close();
ncm_dec_close(dec);
pthread_join(n2, NULL);
stream_src_abort(src2); /* 已完成则无副作用 */
/* 续传玩法:读端从头跟播,产出 PCM 应与整首离线解码逐字节一致(金标准) */
CHECK(rr.pcm.n == golden->n && memcmp(rr.pcm.b, golden->b, golden->n) == 0,
"续传 PCM 与整首离线解码逐字节一致 (%zu/%zu)", rr.pcm.n, golden->n);
free(rr.pcm.b);
/* commit 转正 + 校验最终文件与源逐字节一致 */
int ck = ncm_cache_commit(w2);
CHECK(ck == 0, "resume commit rc=%d", ck);
if (ck == 0) {
char fp[700];
if (ncm_cache_path(&cache, key, "mp3", fp, sizeof fp) == NCM_OK) {
FILE *f = fopen(fp, "rb");
if (!f) { printf(" FAIL committed missing\n"); g_fail = 1; }
else {
unsigned char *got = (unsigned char *)malloc(size);
size_t rn = fread(got, 1, size, f);
int extra = fgetc(f);
fclose(f);
CHECK(rn == size && extra == EOF && memcmp(got, data, size) == 0,
"committed == source (%zu/%zu bytes)", rn, size);
free(got);
}
}
}
stream_src_close(src2);
printf(" [%s] resume .part=%llu wall=%.2fs first_sound=%.2fs frames=%lld underruns=%d\n",
tag, (unsigned long long)part_size, rr.wall_s, rr.first_sound_s,
(long long)rr.frames, rr.underruns);
return 0;
}
int main(int argc, char **argv)
{
(void)argc;
@@ -447,6 +561,12 @@ int main(int argc, char **argv)
free(fg.b);
}
/* 阶段3:.part 续传——中断 40% → 存 .part → 续传(略过已下前缀)→ 完整 PCM 金标准比对 */
printf("== CASE resume@10MB/s (40%% cut) ==\n");
run_resume_case("resume@10MB/s", data, size, 10485760u, 0, 0, 0.4, &golden);
printf("== CASE resume@400kbps (40%% cut, 慢网续传) ==\n");
run_resume_case("resume@400kbps", data, size, 50000u, 10, 1, 0.4, &golden);
free(golden.b); free(golden_cut.b); free(data);
printf(g_fail ? "== STREAM SIM: FAIL ==\n" : "== STREAM SIM: ALL PASS ==\n");
return g_fail;
+94 -2
View File
@@ -26,10 +26,12 @@ typedef struct entry {
int64_t mtime;
} entry_t;
static int gb_to_bytes(int gb) {
/* 阶段3 顺手修:原返回 int,10GB(10*1024^3=10737418240)与 100GB 溢出 int 成 0/巨大值,
* 导致默认 10GB 上限实际"无上限"(evict 永不触发,缓存上限设置失效)——改 uint64_t 修真。 */
static uint64_t gb_to_bytes(int gb) {
if (gb < MIN_LIMIT_GB) gb = MIN_LIMIT_GB;
if (gb > MAX_LIMIT_GB) gb = MAX_LIMIT_GB;
return (int)((uint64_t)gb * 1024ULL * 1024ULL * 1024ULL);
return (uint64_t)gb * 1024ULL * 1024ULL * 1024ULL;
}
int ncm_cache_init(ncm_cache_t *c, const char *dir) {
@@ -140,6 +142,18 @@ const char *ncm_cache_writer_tmp_path(const ncm_cache_writer_t *w) {
return w ? w->tmp_path : "";
}
/* 半成品判定(阶段3,§5.1):.tmp(下载中)/ .part(中断残体)不属正式缓存条目,
* 不进 LRU 扫描/计数——否则正被下载播放的文件会被缓存上限误删(写者管理域)。 */
static int is_partial_name(const char *name)
{
if (!name) return 0;
size_t n = strlen(name);
const char *end = name + n;
if (n >= 4 && memcmp(end - 4, ".tmp", 4) == 0) return 1;
if (n >= 5 && memcmp(end - 5, ".part", 5) == 0) return 1;
return 0;
}
static int cmp_mtime_asc(const void *a, const void *b) {
const entry_t *x = (const entry_t *)a, *y = (const entry_t *)b;
if (x->mtime < y->mtime) return -1;
@@ -158,6 +172,7 @@ int ncm_cache_evict(const ncm_cache_t *c) {
struct dirent *e;
while ((e = readdir(d)) && n < MAX_ENTRIES) {
if (strncmp(e->d_name, "song_", 5) != 0) continue;
if (is_partial_name(e->d_name)) continue; /* 阶段3:.tmp/.part 归写者管理域,不淘汰 */
char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8];
snprintf(p, sizeof p, "%s/%s", c->dir, e->d_name);
struct stat st;
@@ -184,6 +199,82 @@ int ncm_cache_evict(const ncm_cache_t *c) {
return NCM_OK;
}
/* ---- 阶段3:.part 断点续传(§5.2)---- */
/* .part 路径 = 正式文件路径 + ".part"(final+".part")。 */
static void part_path_of(const ncm_cache_t *c, const char *id, const char *ext,
char *part, size_t part_sz)
{
char final[NCM_CACHE_DIR_MAX + 64];
if (ncm_cache_path(c, id, ext, final, sizeof final) == 0) {
snprintf(part, part_sz, "%s.part", final);
} else {
part[0] = 0;
}
}
int ncm_cache_part_size(const ncm_cache_t *c, const char *id, const char *ext, uint64_t *size_out)
{
if (!c || !id || !size_out) return NCM_ERR_ARG;
char part[NCM_CACHE_DIR_MAX + 80];
part_path_of(c, id, ext, part, sizeof part);
if (!part[0]) return NCM_ERR_FULL;
struct stat st;
if (stat(part, &st) != 0 || !S_ISREG(st.st_mode)) return NCM_ERR_NOENT;
*size_out = (uint64_t)st.st_size;
return NCM_OK;
}
int ncm_cache_part_remove(const ncm_cache_t *c, const char *id, const char *ext)
{
if (!c || !id) return NCM_ERR_ARG;
char part[NCM_CACHE_DIR_MAX + 80];
part_path_of(c, id, ext, part, sizeof part);
if (!part[0]) return NCM_ERR_FULL;
unlink(part);
return NCM_OK;
}
int ncm_cache_open_resume(const ncm_cache_t *c, const char *id, const char *ext,
ncm_cache_writer_t **w_out)
{
if (!c || !id || !w_out) return NCM_ERR_ARG;
ncm_cache_writer_t *w = (ncm_cache_writer_t *)calloc(1, sizeof *w);
if (!w) return NCM_ERR_OOM;
w->fd = -1;
if (ncm_cache_path(c, id, ext, w->final_path, sizeof w->final_path) != 0) {
free(w); return NCM_ERR_FULL;
}
snprintf(w->tmp_path, sizeof w->tmp_path, "%s.part", w->final_path);
/* 追加打开已存在的 .part:保留已写字节,write 从尾部续(断点续传)。 */
w->fd = open(w->tmp_path, O_WRONLY | O_APPEND, 0644);
if (w->fd < 0) { free(w); return NCM_ERR_NOENT; }
struct stat st;
if (fstat(w->fd, &st) != 0 || st.st_size <= 0) {
close(w->fd); free(w); return NCM_ERR_NOENT;
}
w->nwritten = (uint64_t)st.st_size;
*w_out = w;
return NCM_OK;
}
/* 中断保留:音频路径专用(.tmp/.part → .part),供下次播放 Range 续传;
* nwritten==0 → 空残体无价值,直接 unlink。封面/测试仍用 ncm_cache_abort(=unlink)。 */
int ncm_cache_abort_part(ncm_cache_writer_t *w)
{
if (!w) return NCM_ERR_ARG;
if (w->fd >= 0) { close(w->fd); w->fd = -1; }
if (w->nwritten > 0) {
char part[NCM_CACHE_DIR_MAX + 80];
snprintf(part, sizeof part, "%s.part", w->final_path);
if (strcmp(w->tmp_path, part) != 0) rename(w->tmp_path, part); /* 已是 .part 则原位保留 */
} else {
unlink(w->tmp_path);
}
free(w);
return NCM_OK;
}
int ncm_cache_commit(ncm_cache_writer_t *w) {
if (!w) return NCM_ERR_ARG;
int rc = NCM_OK;
@@ -219,6 +310,7 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c) {
struct dirent *e;
while ((e = readdir(d))) {
if (strncmp(e->d_name, "song_", 5) != 0) continue;
if (is_partial_name(e->d_name)) continue; /* 阶段3:半成品不计入池占用 */
char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8];
snprintf(p, sizeof p, "%s/%s", c->dir, e->d_name);
struct stat st;
+19 -2
View File
@@ -54,10 +54,27 @@ int ncm_cache_abort(ncm_cache_writer_t *w);
/* 写者 .tmp 路径(只读):边下边播解码侧用它开只读 fd 建 stream_src。 */
const char *ncm_cache_writer_tmp_path(const ncm_cache_writer_t *w);
/* 当前缓存总字节(扫描目录) */
/* ---- 阶段3:.part 断点续传 + 在播文件防淘汰(NCM_STREAM_PLAY §5) ----
* 命名约定:中断的 .tmp 保留为 final+".part"(残体),供下次播放「Range 续传」;
* 半成品 .tmp/.part 不参与 LRU 扫描/计数(evict/total_bytes 跳过),防正被下载播放的
* 文件被缓存上限挤掉。.tmp/.part 归写者自己的管理域。 */
/* 检测 .part 残体大小:存在且>0 → 写 *size_out 返回 NCM_OK;无 → NCM_ERR_NOENT。 */
int ncm_cache_part_size(const ncm_cache_t *c, const char *id, const char *ext, uint64_t *size_out);
/* 删除 .part 残体(续传目标已失效/服务器拒绝 Range 时放弃续传)。 */
int ncm_cache_part_remove(const ncm_cache_t *c, const char *id, const char *ext);
/* 续传打开:以「追加」打开已存在的 .part(final+".part",O_APPEND 保留已写字节,
* nwritten 置 part_size)。commit rename .part→final;abort(abort_part) 原地保留残体。
* part 不存在/空 → NCM_ERR_NOENT(调用方转整首重下)。 */
int ncm_cache_open_resume(const ncm_cache_t *c, const char *id, const char *ext,
ncm_cache_writer_t **w);
/* 中断保留:close fd 后 nwritten>0 → rename .tmp/.part → .part(跨 session 续传);
* nwritten==0 → unlink(空残体无价值)。音频路径用;封面/测试仍用 ncm_cache_abort。 */
int ncm_cache_abort_part(ncm_cache_writer_t *w);
/* 当前缓存总字节(扫描目录;跳过 .tmp/.part 半成品) */
uint64_t ncm_cache_total_bytes(const ncm_cache_t *c);
/* 立即执行 LRU 淘汰(超上限则删最旧,直到 <= 上限)。 */
/* 立即执行 LRU 淘汰(超上限则删最旧,直到 <= 上限;跳过 .tmp/.part 半成品)。 */
int ncm_cache_evict(const ncm_cache_t *c);
/* 清空缓存池:删除 song_* 全部文件(mp3 与封面同池,含残留 .tmp)。 */
+12 -4
View File
@@ -253,6 +253,7 @@ typedef struct sink {
ncm_http_write_cb cb;
void *cb_ctx;
int overflow;
int *cb_status; /* 阶段3:parse_headers 后、首 body 前写入 resp->status(调用方续传分支用) */
} sink_t;
static void sink_write(sink_t *s, const unsigned char *data, size_t len) {
@@ -450,6 +451,7 @@ static int http_run(const ncm_http_req_t *req, sink_t *sink, ncm_http_resp_t *re
char *split = strstr(resp->headers, "\r\n\r\n");
if (split) {
parse_headers(resp);
if (sink->cb_status) *sink->cb_status = resp->status; /* 首 body 前预知状态(续传分支) */
body_st_init(&bst, resp);
/* split+4 即 body 起始;注意其与 resp->headers+consumed 是同一地址,
故先送 body 再在 consumed 处 NUL 终结头部,否则会覆盖 body 首字节。 */
@@ -512,17 +514,23 @@ int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_ht
return rc;
}
int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t max_bytes, ncm_http_resp_t *resp) {
int ncm_http_download_status(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t max_bytes, ncm_http_resp_t *resp, int *status_out) {
if (!req || !cb || !resp) return NCM_ERR_ARG;
if (status_out) *status_out = 0;
memset(resp, 0, sizeof *resp);
sink_t s;
memset(&s, 0, sizeof s);
s.cb = cb;
s.cb_ctx = ctx;
s.max_bytes = max_bytes;
int rc = http_run(req, &s, resp);
return rc;
s.cb_status = status_out;
return http_run(req, &s, resp);
}
int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t max_bytes, ncm_http_resp_t *resp) {
return ncm_http_download_status(req, cb, ctx, max_bytes, resp, NULL);
}
int ncm_http_parse_set_cookie(const char *hdrs, size_t len, char *out, size_t out_sz) {
+5
View File
@@ -64,6 +64,11 @@ int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz,
typedef int (*ncm_http_write_cb)(void *ctx, const unsigned char *data, size_t len);
int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t max_bytes, ncm_http_resp_t *resp);
/* 流式下载(状态感知,阶段3 续传):与 ncm_http_download 相同,但在**首个 body 字节
* 投递前**把解析出的 resp->status 写入 *status_out(同线程、同步),调用方回调据此
* 预知 200/206/416 分支做写姿态切换。*status_out 可为 NULL。 */
int ncm_http_download_status(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t max_bytes, ncm_http_resp_t *resp, int *status_out);
/* 从响应头提取所有 Set-Cookie 的 name=value(去掉 Domain/Path/Expires 等属性),
* 以 "; " 连接写入 out(用于登录捕获 MUSIC_U 等)。成功返回 cookie 条数,失败 -1。 */
Binary file not shown.
Binary file not shown.
Binary file not shown.