diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index a0c6dae..87a503d 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -1050,3 +1050,123 @@ c73366373b3a372d96b2218e528c6381 build_ncm_host.sh | `NCM_SONG_BATCH` | 64 | 100 | song_detail 每批(≤500 安全窗内保守 100;`clist[4096]` 装 100 首 ≈2KB) | | `NCM_PLAYLIST_PAGE` | 50 | 删除 | track/all 服务端分页遗留(该端点 404),改由 `NCM_SONG_BATCH` 承担页粒度 | | `g_st.track_ids_count` | — | 新增 | detail 取回 trackIds 总数,驱动「还有下一页」判据 | + +--- + +## 15. 三期第二批:中文搜索接入拼音 IME + 设置页做好 + 缓存淘汰接线(2026-08-31,agent-241) + +> 范围:仅 `patch_v2/ncm_app/`(含 `core/`)。未动 `sdk/`(主会话在改 `meow_ui_theme.c`/测试,另有 agent 在改 +> `meow_launcherd/` 主题)与 `meow_launcherd/`。风控纪律:本批真实 NCM 请求 **0 次**(全部离线 stub/宿主 mock/ +> 已落盘样本)。部署/验收留用户(真机验收点见 §15.7)。上一批(歌单分页/封面/歌词锁)已 commit `ee104f1`, +> 本批在其上做;提交见 git。 + +### 15.1 中文搜索接入拼音 IME + +- 搜索页(`app/ui/search.c`)输入框 `meow_textfield_create(..., MEOW_IME_ASCII)` 改 **`MEOW_IME_PINYIN`**。 + SDK `MEOW_IME_PINYIN`(M2.88 agent-229 落地)打开后为 4 行字母键 + 组合条 + 候选栏(≤6 候选/页,翻页 + + 「清」+「拼/英」键),候选上屏走 `py_commit` UTF-8 写进字段并触发 `on_change`(TextField 的 + `textfield_change_cb` 实时回写 `g_st.search_query` + `invalidate`)。 +- **候选上屏/清空/退格语义**均沿用 SDK IME(`py_commit`/`py_clear`/`py_back_combo`+`py_back_field`,组合态 + UTF-8 安全退格 = 删末字;组合非空时退格删组合末字母),App 侧零改动。 +- **英文输入零回归**:拼音键盘含「拼/英」键(`K_SWITCH`→ASCII)与 ASCII 键盘顶条「中」热区(→拼音), + ASCII/NUM 不随本批改动;搜索仍只在用户点「搜索」按钮时发 `cloudsearch`(风控:进页不自动搜,不逐键发)。 +- 保留 `search_submit`/`search_worker`(后台线程 cloudsearch,UI 不卡);`ncm_api_cloudsearch` 的 keyword + 为 UTF-8 字符串,中文关键词直接进 weapi 加密体(无需改造)。 + +### 15.2 设置页做好(用户定案:音质/缓存上限可设数字+清理缓存+关于) + +`app/ui/settings.c` 重做为 **5 行**:音质 / 缓存上限 / 清理缓存 / 退出登录 / 关于网易云。 + +- **音质**:sheet 列出全 5 档(Hi-Res/Lossless/Exhigh/Higher/Standard,默认 Hi-Res)。单曲不可用时 + **沿用 core 既有降级链**(`ncm_api_song_url_auto`:hires→lossless→exhigh→higher→standard,`ncm_api.c:260`), + 本批未改 core 降级逻辑。 +- **缓存上限**:sheet 预设(1/2/5/10/20/30/50/100 GB)+「自定义...」→ INPUT 弹窗 + **NUM IME 数字输入** + (`input_cache_limit_gb()`,atoi + 钳制 1~100)。默认 10GB,上限 100GB。持久化走既有 + `appstate_save_settings`(`meow_config_*` → `/ncm.cache_limit_gb.cfg`,即 + `MeowData/dev.player.ncm/data/`,与 core `ncm_cache_load_setting`/`ncm_store_read_cfg_int` 同目录同键)。 +- **清理缓存(新)**:显示当前占用(`ncm_cache_total_bytes`,进页时算一次存 `g_cache_used`,清理后清零); + tap → CONFIRM 弹窗 → `ncm_cache_clear`(新 core API)→ snack「缓存已清理」。占用用整数运算格式化 + (`fmt_bytes`,GB/MB/KB,不用浮点/软浮点)。 +- **退出登录 / 关于/版本行**:保留;关于 tap 改为 snack「网易云音乐 v0.1 · MeowOS 移植版」。 + +### 15.3 缓存淘汰接线(ncm_cache_evict 接进生产路径) + +- **core 新增 `ncm_cache_clear`**(`core/ncm_cache.c/.h`):删除 `song_*` 全部(mp3 与封面同池,含残留 + `.tmp`),供「清理缓存」与测试用;`ncm_cache_evict`(既有)保持 LRU=按 mtime 升序删最旧直到 `<= limit`。 +- **mp3 下载路径**(`app/play/player_manager.c song_local_file`):**写前**(`ncm_cache_open` 前)与**写后** + (`ncm_cache_commit` 后)各调一次 `ncm_cache_evict(&g_st.ctx.cache)`——写前腾空间(此时本线程 temp 尚未建, + 无在途文件被误删风险),写后清「新文件推过上限」的增量。刚写的文件 mtime 最新,LRU 淘汰时存活。 +- **封面路径**(`app/logic/cover.c` cover_worker 未命中写盘分支):写前 + 写后同样各调一次 `ncm_cache_evict` + (封面 `song_cov..jpg` 与 mp3 同池)。 +- **手动调低上限**:`appstate_save_settings` 末尾加一次 `ncm_cache_evict`,用户从 50GB 降到 1GB 立即淘汰。 +- 上限读设置值:`ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb)`(`player_manager_set_quality` + 与 `appstate_save_settings` 均已同步,继 `appstate_load_settings`/`ncm_cache_load_setting`)。 + +### 15.4 验证记录(0 真实 NCM 请求) + +| 项 | 方式 | 结果 | +|---|---|---| +| 缓存 LRU 淘汰/清空 | `bench/cache_evict_sim.c` 离线(0 网络,稀疏文件构造,直接驱动 core) | **全 PASS**:①超上限删最旧(700+300+400MB→删 700 停于 700MB)②删 2 个最旧到 ≤1GB(4×500MB→留 2)③未超上限无操作 ④`lookup` 刷新 mtime → 被访问的 A 存活、B/C 被淘汰 ⑤`set_limit_gb` 钳制(0→1、200→100)⑥`clear` 删 mp3+封面+残留 .tmp(total=0)| +| 歌单分页回归 | `pagination_sim`(0 网络) | **全 PASS**(130/5191/空 三态无回归;`appstate.c` 加 `ncm_cache_evict` 未破坏分页——`--gc-sections` 丢弃不可达的 `save_settings`,链接不受影响) | +| core 宿主测试 | `test_ncm_core_host`(重建 core-host) | **PASS** | +| core mipsel 测试 | `qemu-mipsel ./out/test_ncm_core_mipsel` | **PASS** | +| mipsel 构建 | `bash build_ncm.sh`(自有代码 `-Wall -Wextra`) | **0 warning**;`text 844640 / data 856 / bss 2052068` | +| 宿主离屏渲染 | `build_ncm_host.sh` + `MEOW_UI_DUMP`(mock,0 网络) | settings(明/暗 5 行 + 占用 + 版本 + 品牌红脚注)/search(输入框+搜索钮+footer)/home/playlist/player 均正常渲染,无崩溃 | + +### 15.5 md5(本批改动文件 + 产物) + +``` +8a313d27dc7d05fa3a78ce7772436b04 ncm # mipsel 静态 App(新) +dc66bdcfb29ba5717b42c72ecbf45049 ncm_host # 宿主离屏调试二进制(重编) +be3d906edce4d90ebd9c09d3b7faf814 core/ncm_cache.c # 新增 ncm_cache_clear +f5810d23709080f4fa39b2fbc242c9e0 core/ncm_cache.h +41ae3f40014f68f7855fed3a0be1558c app/ui/settings.c # 设置页 5 行 + 自定义数字 + 清理缓存 +9811af8f7150b325225e067748ca50a3 app/ui/search.c # 输入框 MEOW_IME_PINYIN +2350622237e9fb5d05a5497dd8b87e39 app/play/player_manager.c # mp3 写前/写后淘汰 +fb1a74a68950b26a2a4f42281725f37d app/logic/cover.c # 封面写前/写后淘汰 +a51b6d8477bde5692089caf511eafca1 app/logic/appstate.c # save_settings 末触发淘汰 +ed2f71c52563e812a04fb8a7cc05148d app/test/test_ncm_app.c # settings mock 初始化缓存目录 +2134ad23a6ee4d1b37120ee8d2e44337 bench/cache_evict_sim.c # 新增 LRU/清空离线回归 +``` + +### 15.6 出入清单(只改 `patch_v2/ncm_app/`) + +``` +修改: + app/ui/settings.c # 设置页 5 行:音质(全5档)/缓存上限(预设+自定义数字)/清理缓存(显示占用)/退出/关于 + app/ui/search.c # 搜索输入框 MEOW_IME_ASCII → MEOW_IME_PINYIN(中文搜索,英文零回归) + app/play/player_manager.c # song_local_file 写前/写后 ncm_cache_evict + app/logic/cover.c # cover_worker 未命中写盘分支 写前/写后 ncm_cache_evict + app/logic/appstate.c # appstate_save_settings 末尾触发 ncm_cache_evict(手动调低上限立即淘汰) + core/ncm_cache.c/.h # 新增 ncm_cache_clear(删 song_* 全部) + app/test/test_ncm_app.c # settings mock 初始化 ctx.cache(「占用」行读真实目录) + ncm / ncm_host / test/test_ncm_core_host # 重编产物 +新增: + bench/cache_evict_sim.c # 缓存 LRU 淘汰/清空 离线回归(0 网络) + REPORT.md # 本批记录(§15) +(未动 sdk/、meow_launcherd/、vendor/;未动设备系统/恢复区;未 killall hiby_player / 未重启设备) +``` + +### 15.7 诚实出入 / 已知限制 + +- **真机未部署/未验**(风控:0 新增真实请求)。部署链路同 §13.5(`scp ncm root@:/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm.new` → `ssh root@ '. /etc/profile; mv ...ncm.new ...ncm'`)。真机验收点: + ① 搜索页输入拼音→候选上屏中文→点「搜索」出中文结果(键盘「拼/英」「中」「退格」「清」语义); + ② 设置页缓存上限「自定义」数字键盘输入 1~100、改上限后立即 LRU 淘汰;③ 清理缓存显示真实占用、(超上限 + 磁盘占用下降);④ 音质默认 Hi-Res、VIP/版权曲逐档降级播放。 +- **`song_..mp3` 双点命名是 core 既有约定**(`ncm_cache_path` 用 `"song_%s.%s"` + ext 带前导点 `.mp3`) + ——本批未改(改动它需动 core 缓存写入路径,风险大于收益);`cache_evict_sim.c` 已按此约定构造文件, + 验证的正是真实生产路径。 +- **跨线程淘汰的极小竞态**:cover 线程写盘时,player 线程的下载 temp(`song_..mp3.tmp`)也匹配 + `song_` 前缀;但 in-progress .tmp 的 mtime 是「刚写」=最新,LRU 升序下排最末,正常淘汰只删最旧, + 不会先删它。仅在「池严重超上限、删净旧文件后仍超」的极端情形才可能触及其(上限 ≥1GB,现实中几乎不 + 发生);沿用既有「双线程同池无锁」模型(本批未扩范围,未引入新锁)。 +- **`ncm_cache_evict` 上限读 `&g_st.ctx.cache`**:宿主 settings mock 未 `ncm_ctx_init`(只 init cache dir), + `ctx.cache.dir` 为空 → 占用行显示 0(渲染验证用);真机 `appstate_init`→`ncm_ctx_init` 后为真实目录。 + +### 15.8 数值权衡(供后续微调) + +| 常量 | 旧值 | 新值 | 说明 | +|---|---|---|---| +| 设置页行数 | 4 | 5 | 新增「清理缓存」行并在「缓存上限」接「自定义」数字输入 | +| 音质 sheet 档数 | 3(Hi-Res/Higher/Standard) | 5(+Lossless/Exhigh) | 对齐 core `NCM_LEVEL_*` 全档 + 降级链起点 | +| `ncm_cache_clear` | — | 新增 | 删除 `song_*` 全部(mp3/封面/.tmp),供「清理缓存」与测试 | diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 7e8b643..f63c737 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -57,6 +57,8 @@ void appstate_save_settings(void) meow_config_set("ncm.level", b); ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); g_st.ctx.default_level = g_st.level; + /* 手动调低上限时立即按 LRU 淘汰(超上限删最旧);未超限则为无操作 */ + ncm_cache_evict(&g_st.ctx.cache); } void appstate_set_logged_in(const char *uid, const char *nickname) diff --git a/patch_v2/ncm_app/app/logic/cover.c b/patch_v2/ncm_app/app/logic/cover.c index 4e423d5..c923d92 100644 --- a/patch_v2/ncm_app/app/logic/cover.c +++ b/patch_v2/ncm_app/app/logic/cover.c @@ -106,10 +106,14 @@ static void *cover_worker(void *arg) img = decode_cover(url, &blen); if (!img) { usleep(500 * 1000); continue; } if (g_st.ctx.cache.dir[0]) { + /* 写前检查:池大小超限先按 LRU 淘汰(与 mp3 同池;封面约 200KB,先腾空间) */ + ncm_cache_evict(&g_st.ctx.cache); ncm_cache_writer_t *w = NULL; if (ncm_cache_open(&g_st.ctx.cache, key, ".jpg", &w) == NCM_OK) { ncm_cache_write(w, g_buf, blen); ncm_cache_commit(w); + /* 写后检查:新封面可能把池推过上限 → 再按 LRU 淘汰最旧 */ + ncm_cache_evict(&g_st.ctx.cache); } } } diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index aa3b523..21e11d5 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -120,6 +120,9 @@ static int song_local_file(int idx, char *path, size_t psz, int64_t *bytes) if (rc != NCM_OK) return rc; if (!u.url[0]) return NCM_ERR_NOTFOUND; + /* 写前检查:池大小超限先按 LRU 淘汰(此线程尚未开 temp,无在途文件被误删风险) */ + ncm_cache_evict(&g_st.ctx.cache); + /* 流式下载到缓存 temp → commit(稳妥者:先落盘再播) */ ncm_cache_writer_t *w = NULL; rc = ncm_cache_open(&g_st.ctx.cache, id, ".mp3", &w); @@ -143,6 +146,8 @@ static int song_local_file(int idx, char *path, size_t psz, int64_t *bytes) } rc = ncm_cache_commit(w); if (rc != 0) return rc; + /* 写后检查:新落盘可能把池推过上限 → 再按 LRU 淘汰最旧(刚写的 mtime 最新,存活) */ + ncm_cache_evict(&g_st.ctx.cache); if (ncm_cache_lookup(&g_st.ctx.cache, id, ".mp3", path, psz) != NCM_OK) return NCM_ERR_NOENT; struct stat st; diff --git a/patch_v2/ncm_app/app/test/test_ncm_app.c b/patch_v2/ncm_app/app/test/test_ncm_app.c index a4929ac..672fb90 100644 --- a/patch_v2/ncm_app/app/test/test_ncm_app.c +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -145,6 +145,8 @@ int main(int argc, char **argv) ncm_strlcpy(g_st.nickname, "Tester", sizeof g_st.nickname); g_st.level = NCM_LEVEL_HIRES; g_st.cache_limit_gb = 10; + ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir); /* 初始化缓存目录(「占用」行读取)*/ + ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); } else if (strcmp(name, "search") == 0) { mock_playlists(); static const char *sn[] = { "Song One", "Song Two", "Song Three" }; diff --git a/patch_v2/ncm_app/app/ui/search.c b/patch_v2/ncm_app/app/ui/search.c index 7eebe52..6d810a6 100644 --- a/patch_v2/ncm_app/app/ui/search.c +++ b/patch_v2/ncm_app/app/ui/search.c @@ -235,9 +235,11 @@ static void sr_page_create(meow_view *v, void *ctx) meow_ui_activity_push(ACT_SEARCH); /* owner=ACT_SEARCH */ g_tb = ncm_topbar_create("搜索", 1, NULL); if (!g_st.search_query[0]) ncm_strlcpy(g_st.search_query, "", sizeof g_st.search_query); + /* 中文搜索:IME 切 MEOW_IME_PINYIN(拼音候选上屏 UTF-8 到 g_st.search_query,经 on_change 实时回写; + * 键盘「拼/英」键可切回 ASCII 输英文,「中」热区回拼音——英文零回归)。 */ g_field = meow_textfield_create(field.x, field.y, field.w, field.h, "输入歌曲 / 歌手", g_st.search_query, - sizeof g_st.search_query, MEOW_IME_ASCII); + sizeof g_st.search_query, MEOW_IME_PINYIN); g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, sr_src2, NULL, MEOW_LIST_SEL_NONE); meow_listview_set_item_h(g_list, APP_ROW_H); diff --git a/patch_v2/ncm_app/app/ui/settings.c b/patch_v2/ncm_app/app/ui/settings.c index 871ebb6..2ffd590 100644 --- a/patch_v2/ncm_app/app/ui/settings.c +++ b/patch_v2/ncm_app/app/ui/settings.c @@ -1,14 +1,17 @@ /* ui/settings.c — 设置页(ACT_SETTINGS)。 * - * 行项:音质(Hi-res/较高/标准,默认 Hi-res)+ 缓存上限(1~100GB,默认 10GB)+ 退出登录。 - * 设置持久化到 MeowData/dev.player.ncm/data(meow_config_*;缓存上限键 ncm.cache_limit_gb 与核心库 - * ncm_cache_load_setting 对齐)。音质/上限经 BottomSheet 选择;退出登录经 confirm 弹窗。 - * 空态/绘制用 custom 绘制物 + 手动行命中(仅 3 行,无需列表滚动)。 + * 行项:音质(Hi-res 默认,单曲不可用时 core ncm_api_song_url_auto 逐档降级)+ 缓存上限 + * (默认 10GB,上限 100GB,支持预设与「自定义」数字输入)+ 清理缓存(显示当前占用)+ + * 退出登录 + 关于/版本。设置持久化到 MeowData/dev.player.ncm/data(meow_config_*,与 core + * ncm_store_read_cfg_int 同目录同键,ncm.cache_limit_gb/ncm.level)。超上限触发 ncm_cache LRU + * 淘汰(写缓存前后 + 手动调低上限时,见 player_manager.c/cover.c/appstate.c)。 + * 空态/绘制用 custom 绘制物 + 手动行命中(仅 5 行,无需列表滚动)。 */ #include "../ncm_app_internal.h" #include #include #include +#include typedef struct { meow_view *view; int active; } settings_ctrl; static settings_ctrl g_sc; @@ -17,6 +20,9 @@ static meow_widget *g_tb; static ncm_rect_t r_cont; static int g_custom_id; static int g_row_h; /* 行高 */ +static uint64_t g_cache_used; /* 缓存占用字节(进设置页时算一次,清理后清零) */ + +#define SETTINGS_ROWS 5 static const char *level_name(int lv) { @@ -29,6 +35,30 @@ static const char *level_name(int lv) } } +/* 缓存占用字节 → 人类可读(整数运算,不用浮点/软浮点)。buf ≥ 24。 */ +static void fmt_bytes(uint64_t b, char *buf, size_t sz) +{ + const uint64_t GB = 1024ULL * 1024 * 1024; + const uint64_t MB = 1024ULL * 1024; + if (b >= GB) { + uint64_t g = b / GB, fr = (b % GB) / (GB / 10); + snprintf(buf, sz, "%llu.%llu GB", (unsigned long long)g, (unsigned long long)fr); + } else if (b >= MB) { + uint64_t m = b / MB, fr = (b % MB) / (MB / 10); + snprintf(buf, sz, "%llu.%llu MB", (unsigned long long)m, (unsigned long long)fr); + } else if (b >= 1024) { + snprintf(buf, sz, "%llu KB", (unsigned long long)(b / 1024)); + } else { + snprintf(buf, sz, "%llu B", (unsigned long long)b); + } +} + +/* 刷新缓存占用缓存(进设置页/清理后调用)。 */ +static void refresh_cache_used(void) +{ + g_cache_used = ncm_cache_total_bytes(&g_st.ctx.cache); +} + static void settings_draw(void *ctx) { (void)ctx; @@ -40,18 +70,23 @@ static void settings_draw(void *ctx) int w = r_cont.w - 2 * APP_PAD; int x = APP_PAD; - /* 行:音质 / 缓存上限 / 退出登录 / 关于(色 = 语义色;退出 = error)。 */ - struct { const char *title; char sub[160]; int red; } rows[4]; - rows[0].title = "音质"; rows[0].red = 0; - rows[1].title = "缓存上限"; rows[1].red = 0; - rows[2].title = "退出登录"; rows[2].red = 1; - rows[3].title = "关于网易云"; rows[3].red = 0; - snprintf(rows[0].sub, sizeof rows[0].sub, "当前: %s", level_name(g_st.level)); - snprintf(rows[1].sub, sizeof rows[1].sub, "当前: %d GB", g_st.cache_limit_gb); - snprintf(rows[2].sub, sizeof rows[2].sub, "%s", g_st.nickname[0] ? g_st.nickname : "退出"); - snprintf(rows[3].sub, sizeof rows[3].sub, "%s", "v0.1 · MeowOS"); + char usetxt[24]; + fmt_bytes(g_cache_used, usetxt, sizeof usetxt); - for (int i = 0; i < 4; i++) { + /* 行:音质 / 缓存上限 / 清理缓存 / 退出登录 / 关于。 */ + struct { const char *title; char sub[160]; int red; } rows[SETTINGS_ROWS]; + rows[0].title = "音质"; rows[0].red = 0; + rows[1].title = "缓存上限"; rows[1].red = 0; + rows[2].title = "清理缓存"; rows[2].red = 0; + rows[3].title = "退出登录"; rows[3].red = 1; + rows[4].title = "关于网易云"; rows[4].red = 0; + snprintf(rows[0].sub, sizeof rows[0].sub, "当前: %s", level_name(g_st.level)); + snprintf(rows[1].sub, sizeof rows[1].sub, "当前: %d GB · 上限 100", g_st.cache_limit_gb); + snprintf(rows[2].sub, sizeof rows[2].sub, "占用: %s", usetxt); + snprintf(rows[3].sub, sizeof rows[3].sub, "%s", g_st.nickname[0] ? g_st.nickname : "退出"); + snprintf(rows[4].sub, sizeof rows[4].sub, "%s", "v0.1 · MeowOS"); + + for (int i = 0; i < SETTINGS_ROWS; i++) { int yy = y + i * (g_row_h + 8); ncm_fill_round(x, yy, w, g_row_h, 12, th->surfaceContainer); uint32_t tcol = rows[i].red ? th->error : th->onSurface; @@ -61,9 +96,9 @@ static void settings_draw(void *ctx) meow_ui_text_right(x + w - APP_PAD, yy + 18, ">", 28, rows[i].red ? th->error : th->onSurfaceVariant); } - /* 分组说明(占位)+ 品牌红小字。 */ - int ny = y + 4 * (g_row_h + 8) + 4; - meow_ui_text(x, ny, "以下为占位,v1 不启用", 20, th->onSurfaceVariant); + /* 品牌红页脚小字。 */ + int ny = y + SETTINGS_ROWS * (g_row_h + 8) + 4; + meow_ui_text(x, ny, "解锁完整音质请登录网易云黑胶会员", 20, th->onSurfaceVariant); meow_ui_text(x, ny + 24, "品牌红 © MeowOS · NCM", 20, pa->red); } @@ -71,13 +106,36 @@ static int row_at(int x, int y) { (void)x; int r = r_cont.y + 16; - for (int i = 0; i < 4; i++) { + for (int i = 0; i < SETTINGS_ROWS; i++) { int yy = r + i * (g_row_h + 8); if (y >= yy && y <= yy + g_row_h) return i; } return -1; } +/* 「自定义」缓存上限:INPUT 弹窗 + NUM IME(数字键盘输入 1~100,越界钳制)。 + * 回车/ESC 关闭后 d->ime.buf 已同步(dialog INPUT 关闭时回写 ime_orig),读回即可。 */ +static int input_cache_limit_gb(void) +{ + char buf[8]; + snprintf(buf, sizeof buf, "%d", g_st.cache_limit_gb); + meow_ime_ctx ime; + memset(&ime, 0, sizeof ime); + ime.mode = MEOW_IME_NUM; + ime.maxlen = sizeof buf; + ncm_strlcpy(ime.buf, buf, sizeof ime.buf); + + meow_dialog *d = meow_dialog_create(MEOW_DLG_INPUT, "缓存上限 (GB, 1~100)"); + meow_dialog_set_ime(d, &ime); + meow_dialog_show(d); + meow_dialog_free(d); + + int gb = atoi(ime.buf); + if (gb < 1) gb = 1; + if (gb > 100) gb = 100; + return gb; +} + int settings_is_active(void) { return g_sc.active; } int settings_on_tap(int x, int y) @@ -91,38 +149,60 @@ int settings_on_tap(int x, int y) int row = row_at(x, y); if (row < 0) return 0; - if (row == 0) { /* 音质 */ + if (row == 0) { /* 音质:Top 档选择,单曲不可用时 core 逐档降级 */ + static const char *lbls[5] = { "Hi-Res", "Lossless", "Exhigh", "Higher", "Standard" }; + static const int lvls[5] = { NCM_LEVEL_HIRES, NCM_LEVEL_LOSSLESS, + NCM_LEVEL_EXHIGH, NCM_LEVEL_HIGHER, NCM_LEVEL_STANDARD }; meow_sheet *s = meow_sheet_create("Music Quality", 0); - meow_sheet_add_item(s, "Hi-Res"); - meow_sheet_add_item(s, "Higher"); - meow_sheet_add_item(s, "Standard"); + for (int i = 0; i < 5; i++) meow_sheet_add_item(s, lbls[i]); int op = meow_sheet_open(s); meow_sheet_free(s); - if (op >= 0) { - g_st.level = (op == 0) ? NCM_LEVEL_HIRES : (op == 1) ? NCM_LEVEL_HIGHER : NCM_LEVEL_STANDARD; + if (op >= 0 && op < 5) { + g_st.level = lvls[op]; player_manager_set_quality(g_st.level, g_st.cache_limit_gb); appstate_save_settings(); meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); } return 1; } - if (row == 1) { /* 缓存上限 */ + if (row == 1) { /* 缓存上限:预设 + 自定义(数字输入 1~100) */ static const int gbs[] = { 1, 2, 5, 10, 20, 30, 50, 100 }; static const char *labels[] = { "1 GB", "2 GB", "5 GB", "10 GB", "20 GB", "30 GB", "50 GB", "100 GB" }; meow_sheet *s = meow_sheet_create("Cache Limit", 0); for (int i = 0; i < 8; i++) meow_sheet_add_item(s, labels[i]); + meow_sheet_add_item(s, "自定义..."); int op = meow_sheet_open(s); meow_sheet_free(s); - if (op >= 0 && op < 8) { - g_st.cache_limit_gb = gbs[op]; + if (op == 8) g_st.cache_limit_gb = input_cache_limit_gb(); + else if (op >= 0 && op < 8) g_st.cache_limit_gb = gbs[op]; + if (op >= 0) { player_manager_set_quality(g_st.level, g_st.cache_limit_gb); - appstate_save_settings(); + appstate_save_settings(); /* 内含手动调低上限后的 LRU 淘汰 */ meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); } return 1; } - if (row == 2) { /* 退出登录 */ + if (row == 2) { /* 清理缓存 */ + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "Clear Cache?"); + char msg[160]; + char usetxt[24]; + fmt_bytes(g_cache_used, usetxt, sizeof usetxt); + snprintf(msg, sizeof msg, "将删除全部缓存 (%s)。\n已播放的歌曲需重新下载。", usetxt); + const char *msgs[1]; + msgs[0] = msg; + meow_dialog_set_items(d, msgs, 1, -1); + int yes = meow_dialog_confirm(d); + meow_dialog_free(d); + if (yes) { + ncm_cache_clear(&g_st.ctx.cache); + refresh_cache_used(); + ncm_snack("缓存已清理"); + meow_ui_invalidate(r_cont.x, r_cont.y, r_cont.w, r_cont.h); + } + return 1; + } + if (row == 3) { /* 退出登录 */ meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "Sign out?"); const char *msgs[1]; msgs[0] = "Playback will stop.\nYou'll need to scan a QR code again."; @@ -135,8 +215,8 @@ int settings_on_tap(int x, int y) } return 1; } - if (row == 3) { /* 关于(v1 占位) */ - ncm_snack("关于页开发中"); + if (row == 4) { /* 关于 */ + ncm_snack("网易云音乐 v0.1 · MeowOS 移植版"); return 1; } return 0; @@ -154,6 +234,7 @@ void settings_open(void) void settings_reload(void) { + refresh_cache_used(); const meow_theme *th = meow_theme_get(); meow_ui_set_page_bg(th->bg); meow_ui_paint_full(); diff --git a/patch_v2/ncm_app/bench/cache_evict_sim.c b/patch_v2/ncm_app/bench/cache_evict_sim.c new file mode 100644 index 0000000..e895a5c --- /dev/null +++ b/patch_v2/ncm_app/bench/cache_evict_sim.c @@ -0,0 +1,201 @@ +/* bench/cache_evict_sim.c — 缓存池 LRU 淘汰 / 清空 离线验证(0 网络,仅本地文件系统)。 + * + * 直接驱动 core 的真实函数 ncm_cache_init / ncm_cache_set_limit_gb / ncm_cache_evict / + * ncm_cache_total_bytes / ncm_cache_clear / ncm_cache_lookup,验证三期第二批接进生产路径的 + * 淘汰语义(REPORT §15): + * ① 超上限按 mtime 升序删最旧,直到 <= 上限; + * ② 未超上限无操作; + * ③ ncm_cache_lookup 刷新 mtime(LRU:被访问的最近优先存活); + * ④ ncm_cache_set_limit_gb 越界钳制(1~100GB); + * ⑤ ncm_cache_clear 删除 song_* 全部(mp3/封面/残留 .tmp)。 + * 文件用 ftruncate 生成稀疏文件(逻辑大小可控,磁盘零消耗)。 + * + * 编译(宿主,链接 core/ncm_cache.c + 本文件): + * $CC_HOST -O2 -DJSMN_HEADER -I. -Icore -c bench/cache_evict_sim.c -o /tmp/ce.o + * $CC_HOST -O2 -DJSMN_HEADER -I. -Icore -c core/ncm_cache.c -o /tmp/ce_cache.o + * $CC_HOST -O2 -o cache_evict_sim /tmp/ce.o /tmp/ce_cache.o -lpthread + * 运行:./cache_evict_sim (0 网络;PASS/FAIL 退出码) + */ +#include "../core/ncm_cache.h" +#include "../core/ncm_common.h" +#include "../core/ncm_store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---- ncm_cache.c 依赖的符号(核心库其他 TU 提供;此处最小内联/桩,避免链 mbedtls) ---- */ +size_t ncm_strlcpy(char *dst, const char *src, size_t sz) +{ + size_t n = 0; + if (sz) { + while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } + dst[n] = 0; + } + while (src[n]) n++; + return n; +} +int ncm_store_mkdir_p(const char *path) +{ + if (!path || !*path) return -1; + char tmp[512]; + size_t len = strlen(path); + if (len >= sizeof tmp) return -1; + memcpy(tmp, path, len + 1); + if (tmp[len - 1] == '/') tmp[len - 1] = '\0'; + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (tmp[0] && mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1; + tmp[i] = ch; + } + } + return 0; +} +int ncm_store_read_cfg_int(const char *dir, const char *key, int def) { (void)dir; (void)key; return def; } + +/* ---- 工具 ---- */ +static char g_dir[512]; +static ncm_cache_t g_c; +static int g_fail; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ +} while (0) + +/* 按真实生产命名建缓存文件:路径经 ncm_cache_path(与 app/play/cover 同约定,ext 带前导点)。 + * 结果文件名 = song_.(ext 带点时即双点,如 song_a..mp3 —— 与 core 现状一致)。 */ +static void make_cached(const char *id, const char *ext, unsigned long long size, long mtime) +{ + char p[600]; + if (ncm_cache_path(&g_c, id, ext, p, sizeof p) != 0) { printf(" path err %s\n", id); g_fail = 1; return; } + int fd = open(p, O_WRONLY | O_CREAT, 0644); + if (fd < 0) { printf(" open %s failed\n", id); g_fail = 1; return; } + if (ftruncate(fd, (off_t)size) != 0) printf(" truncate %s err\n", id); + close(fd); + struct timespec ts[2]; + ts[0].tv_sec = mtime; ts[0].tv_nsec = 0; + ts[1].tv_sec = mtime; ts[1].tv_nsec = 0; + utimensat(AT_FDCWD, p, ts, 0); +} + +static int cached_exists(const char *id, const char *ext) +{ + char p[600]; + if (ncm_cache_path(&g_c, id, ext, p, sizeof p) != 0) return 0; + struct stat st; + return stat(p, &st) == 0; +} + +static unsigned long long mb(unsigned long long n) { return n * 1024ULL * 1024ULL; } + +static void cleanup(void) +{ + /* 递归删 g_dir 下 song_*(恢复干净池) */ + DIR *d = opendir(g_dir); + if (d) { + struct dirent *e; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "song_", 5) != 0) continue; + char p[800]; + snprintf(p, sizeof p, "%s/%s", g_dir, e->d_name); + unlink(p); + } + closedir(d); + } +} + +int main(void) +{ + snprintf(g_dir, sizeof g_dir, "/tmp/ncm_cache_sim_%d", (int)getpid()); + cleanup(); + ncm_cache_init(&g_c, g_dir); + ncm_cache_set_limit_gb(&g_c, 1); /* 1GB 最小上限(淘汰只在 total > limit 时触发) */ + + /* ① 超上限删最旧。A=700MB(最旧), B=300MB, C=400MB(最新) → 删 A 后 700MB<=1GB 停。 */ + printf("== case 1: evict oldest over-limit ==\n"); + cleanup(); + make_cached("a", ".mp3", mb(700), 1000); + make_cached("b", ".mp3", mb(300), 1100); + make_cached("c", ".mp3", mb(400), 1200); + CHECK(ncm_cache_total_bytes(&g_c) == mb(1400), "total=%llu (expect 1400MB)", + (unsigned long long)ncm_cache_total_bytes(&g_c)); + ncm_cache_evict(&g_c); + CHECK(!cached_exists("a", ".mp3"), "oldest A evicted"); + CHECK(cached_exists("b", ".mp3") && cached_exists("c", ".mp3"), "B/C kept"); + CHECK(ncm_cache_total_bytes(&g_c) == mb(700), "total=%llu after evict (expect 700MB)", + (unsigned long long)ncm_cache_total_bytes(&g_c)); + + /* ② 4×500MB 超 1GB:删 2 个最旧到 <=1GB。 */ + printf("== case 2: evict two oldest ==\n"); + cleanup(); + make_cached("a", ".mp3", mb(500), 1000); + make_cached("b", ".mp3", mb(500), 1100); + make_cached("c", ".mp3", mb(500), 1200); + make_cached("d", ".mp3", mb(500), 1300); + ncm_cache_evict(&g_c); + CHECK(!cached_exists("a", ".mp3") && !cached_exists("b", ".mp3"), "A/B (two oldest) evicted"); + CHECK(cached_exists("c", ".mp3") && cached_exists("d", ".mp3"), "C/D kept"); + CHECK(ncm_cache_total_bytes(&g_c) == mb(1000), "total=%llu (expect 1000MB)", + (unsigned long long)ncm_cache_total_bytes(&g_c)); + + /* ③ 未超上限:无删除。 */ + printf("== case 3: under limit no-op ==\n"); + cleanup(); + make_cached("a", ".mp3", mb(300), 1000); + make_cached("b", ".mp3", mb(200), 1100); + ncm_cache_evict(&g_c); + CHECK(cached_exists("a", ".mp3") && cached_exists("b", ".mp3"), "all kept under limit"); + + /* ④ lookup 刷新 mtime(LRU):A 被访问 → 变最新,淘汰先删 B/C。 */ + printf("== case 4: lookup refreshes LRU mtime ==\n"); + cleanup(); + make_cached("a", ".mp3", mb(600), 1000); + make_cached("b", ".mp3", mb(600), 1100); + make_cached("c", ".mp3", mb(600), 1200); + char path[600]; + CHECK(ncm_cache_lookup(&g_c, "a", ".mp3", path, sizeof path) == NCM_OK, "lookup A hit"); + ncm_cache_evict(&g_c); /* A mtime 已被刷新为最新 → 删 B、C */ + CHECK(cached_exists("a", ".mp3"), "A (recently accessed) survives"); + CHECK(!cached_exists("b", ".mp3") && !cached_exists("c", ".mp3"), "B/C evicted"); + + /* ⑤ set_limit_gb 钳制。 */ + printf("== case 5: limit clamping ==\n"); + ncm_cache_set_limit_gb(&g_c, 0); + CHECK(ncm_cache_limit_gb(&g_c) == 1, "limit(0)->%d (expect 1)", ncm_cache_limit_gb(&g_c)); + ncm_cache_set_limit_gb(&g_c, 200); + CHECK(ncm_cache_limit_gb(&g_c) == 100, "limit(200)->%d (expect 100)", ncm_cache_limit_gb(&g_c)); + + /* ⑥ clear 删 song_* 全部(mp3 + 封面 + 残留 .tmp)。 */ + printf("== case 6: clear removes all (mp3+cover+tmp) ==\n"); + cleanup(); + make_cached("x", ".mp3", mb(10), 1000); + make_cached("y", ".mp3", mb(10), 1001); + make_cached("cov123", ".jpg", mb(1), 1002); + /* 残留 .tmp:取 mp3 生产路径后追加 .tmp(ncm_cache_open 的临时名)。 */ + { + char tp[600]; + ncm_cache_path(&g_c, "z", ".mp3", tp, sizeof tp); + strncat(tp, ".tmp", sizeof tp - strlen(tp) - 1); + int fd = open(tp, O_WRONLY | O_CREAT, 0644); + if (ftruncate(fd, (off_t)mb(5)) != 0) printf(" truncate tmp err\n"); + close(fd); + } + CHECK(ncm_cache_clear(&g_c) == NCM_OK, "clear rc=0"); + CHECK(!cached_exists("x", ".mp3") && !cached_exists("y", ".mp3") && + !cached_exists("cov123", ".jpg") && ncm_cache_total_bytes(&g_c) == 0, + "all song_* (mp3+cover+tmp) removed; total=0"); + CHECK(ncm_cache_total_bytes(&g_c) == 0, "total=%llu after clear (expect 0)", + (unsigned long long)ncm_cache_total_bytes(&g_c)); + + cleanup(); + printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/core/ncm_cache.c b/patch_v2/ncm_app/core/ncm_cache.c index 441baaa..5a88d18 100644 --- a/patch_v2/ncm_app/core/ncm_cache.c +++ b/patch_v2/ncm_app/core/ncm_cache.c @@ -200,3 +200,19 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c) { closedir(d); return total; } + +/* 清空缓存池:删除 song_* 全部文件(mp3 与封面同池,含残留 .tmp)。返回 0=成功。 */ +int ncm_cache_clear(const ncm_cache_t *c) { + if (!c) return NCM_ERR_ARG; + DIR *d = opendir(c->dir); + if (!d) return NCM_ERR_IO; + struct dirent *e; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "song_", 5) != 0) continue; + char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; + snprintf(p, sizeof p, "%s/%s", c->dir, e->d_name); + unlink(p); + } + closedir(d); + return NCM_OK; +} diff --git a/patch_v2/ncm_app/core/ncm_cache.h b/patch_v2/ncm_app/core/ncm_cache.h index 17d50a8..46ae336 100644 --- a/patch_v2/ncm_app/core/ncm_cache.h +++ b/patch_v2/ncm_app/core/ncm_cache.h @@ -56,6 +56,9 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c); /* 立即执行 LRU 淘汰(超上限则删最旧,直到 <= 上限)。 */ int ncm_cache_evict(const ncm_cache_t *c); +/* 清空缓存池:删除 song_* 全部文件(mp3 与封面同池,含残留 .tmp)。 */ +int ncm_cache_clear(const ncm_cache_t *c); + #ifdef __cplusplus } #endif diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 01914c3..5267d60 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 b7f898f..086bfd7 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 084eb86..fd23257 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