diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 6cd277c..749bb0a 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -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_..part`; + 下次播放同曲走 `Range: bytes=-` 一次续传: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 留主会话。 diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index a0be691..6956355 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -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=- */ + 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) diff --git a/patch_v2/ncm_app/app/play/stream_src.c b/patch_v2/ncm_app/app/play/stream_src.c index e720298..27806dd 100644 --- a/patch_v2/ncm_app/app/play/stream_src.c +++ b/patch_v2/ncm_app/app/play/stream_src.c @@ -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; diff --git a/patch_v2/ncm_app/app/play/stream_src.h b/patch_v2/ncm_app/app/play/stream_src.h index 122248a..64d01bb 100644 --- a/patch_v2/ncm_app/app/play/stream_src.h +++ b/patch_v2/ncm_app/app/play/stream_src.h @@ -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); diff --git a/patch_v2/ncm_app/bench/cache_evict_sim.c b/patch_v2/ncm_app/bench/cache_evict_sim.c index e895a5c..745ef70 100644 --- a/patch_v2/ncm_app/bench/cache_evict_sim.c +++ b/patch_v2/ncm_app/bench/cache_evict_sim.c @@ -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; diff --git a/patch_v2/ncm_app/bench/player_flow_sim.c b/patch_v2/ncm_app/bench/player_flow_sim.c index 676c4eb..e2d0f49 100644 --- a/patch_v2/ncm_app/bench/player_flow_sim.c +++ b/patch_v2/ncm_app/bench/player_flow_sim.c @@ -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"); diff --git a/patch_v2/ncm_app/bench/stream_sim.c b/patch_v2/ncm_app/bench/stream_sim.c index 8bc0739..cf0cf1f 100644 --- a/patch_v2/ncm_app/bench/stream_sim.c +++ b/patch_v2/ncm_app/bench/stream_sim.c @@ -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; diff --git a/patch_v2/ncm_app/core/ncm_cache.c b/patch_v2/ncm_app/core/ncm_cache.c index 8290794..06f9419 100644 --- a/patch_v2/ncm_app/core/ncm_cache.c +++ b/patch_v2/ncm_app/core/ncm_cache.c @@ -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; diff --git a/patch_v2/ncm_app/core/ncm_cache.h b/patch_v2/ncm_app/core/ncm_cache.h index de580b3..88de9b9 100644 --- a/patch_v2/ncm_app/core/ncm_cache.h +++ b/patch_v2/ncm_app/core/ncm_cache.h @@ -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)。 */ diff --git a/patch_v2/ncm_app/core/ncm_http.c b/patch_v2/ncm_app/core/ncm_http.c index 4a26149..af6cbba 100644 --- a/patch_v2/ncm_app/core/ncm_http.c +++ b/patch_v2/ncm_app/core/ncm_http.c @@ -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) { diff --git a/patch_v2/ncm_app/core/ncm_http.h b/patch_v2/ncm_app/core/ncm_http.h index 41fd3bb..e21eea1 100644 --- a/patch_v2/ncm_app/core/ncm_http.h +++ b/patch_v2/ncm_app/core/ncm_http.h @@ -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。 */ diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index e0ec135..ca496fc 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index b4f0fbc..42e85ef 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ diff --git a/patch_v2/ncm_app/test/test_ncm_core_host b/patch_v2/ncm_app/test/test_ncm_core_host index ec57718..c96f1be 100755 Binary files a/patch_v2/ncm_app/test/test_ncm_core_host and b/patch_v2/ncm_app/test/test_ncm_core_host differ