From 39465ea1efc5e9fe35e7e61a0f40d1c9e9bb16e6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:35:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(ncm):=20=E6=96=B0=E5=86=99=E5=85=A5?= =?UTF-8?q?=E7=9A=84=E7=BC=93=E5=AD=98=E6=96=87=E4=BB=B6=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2=E5=85=83=E6=95=B0=E6=8D=AE=EF=BC=88FLAC/MP3?= =?UTF-8?q?=EF=BC=8Ccommit=20=E5=89=8D=E6=B5=81=E5=BC=8F=E9=87=8D=E5=86=99?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E9=99=8D=E7=BA=A7=E8=A3=B8=E6=B5=81?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户需求:保留元数据分离的同时把歌曲元数据嵌进缓存文件(否则外部播放器读不到任何信息)。 - 接入点 player_manager:下载完整(dl_rc==OK)→ stream_src_finish(读端先自然收尾)→ ncm_tag_embed_file(.tmp, 实际扩展名, {name/art_name/album_name}) → dispose join 之后才 commit。tag 重写是同目录 .tag.tmp + rename 换 inode,解码端全程持旧 fd,播放/seek/ .part 续传字节语义零改动。 - 容器按实际 magic 分支(ncm_tag.c);峰值内存 64KB;失败只记 WARN、commit 裸流, 播放主链路不受影响(用户红线)。 - 元数据分离体系不动:/meta/.bin 旁路照旧写读;LRU 只 stat 文件, 无 size/etag 索引需回填(重写后 stat 自然一致)。 - test_ncm_core 新增 test_cache_tag:writer 写裸流 → 嵌入 ID3v2.3 / VORBIS_COMMENT → 读回 → ncm_cache_scan(hires 名 + 320k mp3 体 → 实际档 EXHIGH,名称来自文件内 tag) → ncm_cache_remove → ncm_cache_clear。 - REPORT §38:设计/内存估算/验证/诚实遗留(本机缺 vendor 与交叉工具链,未跑整链与真机)。 --- patch_v2/ncm_app/REPORT.md | 76 ++++++++++++ patch_v2/ncm_app/app/play/player_manager.c | 21 +++- patch_v2/ncm_app/test/test_ncm_core.c | 138 +++++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index bfbf980..12a9f91 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -3293,3 +3293,79 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进 - **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。 - **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。 - **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。 + +## 38. M5:已缓存列表页 + 缓存文件内元数据嵌入(2026-11-04) + +用户原话:「优化一下网易云的部分逻辑。在首页顶栏新加一个已缓存列表入口。另外,缓存文件的配置改一下: +在保留元数据分离的同时,将歌曲元数据嵌入缓存文件中(不然在其他播放器中读不到任何信息)。」 + +### 38.1 功能①:首页顶栏「已缓存」入口 + 缓存列表页 + +- 首页 topbar 新增动作 3(`MEOW_ICON_DOWNLOAD` 位图,`app/ui/home.c`),原「回播放页」顺延为动作 4; + 点击 `cache_open()` → push `ACT_CACHE`(新 activity id 7)。 +- 新页 `app/ui/cache.c`(对齐 queue.c 惯例:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮): + - 行:歌名 / `歌手 · 实际档位 · 大小`;空态「暂无缓存」;页脚「N 首 · 合计 X MB」。 + - 点行 = 点播(切 `NCM_QUEUE_CACHE` 队列,上/下一首在缓存列表内循环);行右「删除」= 单曲删除; + 顶栏「清空」= 确认框 + `ncm_cache_clear`(与设置页「清理缓存」同语义:音频 + 封面条目)。 + - **数据全在 worker**:`cache_reload` 起线程 → `appstate_cache_list_load()` → + `ncm_cache_scan()`(读目录 + 文件头 tag + 音频参数探测)→ 快照;UI 线程只读快照(零磁盘 IO)。 +- 播放链路:`NCM_QUEUE_CACHE`(`appstate_queue_count/song`、`player_play_cache_song`); + `player_manager.c` 的 `song_local_file` 对缓存队列用**条目真实键**(id + 文件名请求档 + 实际扩展名) + 直接命中缓存文件(不按用户当前档键重下),`eff_level` 回填实际档。 + +### 38.2 功能②:缓存文件内元数据嵌入(保留元数据分离) + +- 新模块 `core/ncm_tag.c/.h`(纯 libc,宿主可单测): + - **FLAC → VORBIS_COMMENT**(type 4,TITLE/ARTIST/ALBUM;插在既有元数据块之后、音频帧之前, + 旧 VORBIS_COMMENT 被替换、PADDING/PICTURE 等原样保留); + - **MP3 → ID3v2.3**(TIT2/TPE1/TALB,UTF-16LE + BOM,中英文/emoji 均可;源已带 ID3v2 时先剥旧标签)。 + - **按实际容器分支**(读 `fLaC` 魔数)——缓存名里的档位段是**请求档**,真机存在 + `song_171650_hires.mp3`(无损请求被降级链落到 exhigh mp3),不能按名字判容器。 + - 写入 = 同目录 `.tag.tmp` + 64KB 分块搬运 + fsync + rename 原子替换;失败即 unlink 临时件、 + 原文件不变 → 调用方 commit 裸流(**降级不影响播放主链路**)。 +- 接入点 `app/play/player_manager.c`:下载完整(`dl_rc==OK`)→ `stream_src_finish`(读端先自然收尾)→ + `ncm_tag_embed_file(.tmp, ext, {name, art_name, album_name})` → 播放线程 `dispose` join 后才 commit。 + 解码端全程持有原 inode 的 fd(tag 重写是 rename 换 inode),播放/seek/续传字节语义零改动。 +- **元数据分离体系不动**:`/meta/.bin` 旁路照旧写读;LRU 只 stat 文件(无 size 索引, + 故无「回填 size/etag」需求——重写后 stat 自然得到新 size)。 + +### 38.3 实际档位(用户/塔台取证要求) + +`ncm_audio_probe_file`(ncm_tag.c)只读文件头:FLAC STREAMINFO(采样率/位深)、MP3 首帧(码率); +`ncm_cache_scan` 用「请求档 ∩ 实际格式能力」得实际档(只降不升): +FLAC 16bit→无损 SQ;24bit/48k→Hi-Res;24bit/96k→高清臻音;24bit/192k→超清母带; +MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires 名 + mp3 体 → 极高 HQ)。 + +### 38.4 内存(设备 56MB,硬约束) + +| 项 | 峰值 | +|---|---| +| tag 嵌入(每次 commit 前) | malloc **64KB** 分块缓冲 + ~2KB 栈(ID3 构建)+ 结构体 ≈ **68KB 瞬时**,完毕即释放 | +| 缓存页扫描(worker) | 快照临时数组 512×232B ≈ **116KB** + 单文件 tag/探测 64KB 顺序复用 ≈ **180KB 瞬时** | +| 常驻 bss | `g_st.cache_items[512]` ≈ **116KB**(+8.5% 于原 1364516B bss;RSS 占比 <0.3%) | + +大文件不整读:FLAC 元数据块/PICTURE、MP3 标签区、音频体全部 ≤64KB 分块;tag 读取只读文件头(≤64KB)。 + +### 38.5 验证 + +| 项 | 结果 | +|---|---| +| 新 `test/test_ncm_tag.c` + `build_ncm_tag_host.sh`(FLAC/MP3 构建+读回、中文 roundtrip、旧标签替换、容器判定、3MB 分块、失败降级、音频头探测) | host **51 PASS / 0 FAIL** | +| `test_ncm_core.c` 新增 `test_cache_tag`(writer→embed→read→scan→remove→clear,含 hires 名/mp3 体实际档断言) | 已加入 core 测试集(本机缺 vendor mbedtls/jsmn 无法整链,见 38.6) | +| 临时单测(真实 ncm_cache.c + ncm_api.c + ncm_tag.c,仅桩 `ncm_strlcpy`/`mkdir_p`) | 16 PASS / 0 FAIL(scan 两条、actual=EXHIGH/HIRES、name/artist 来自文件内 tag、remove/clear) | +| 全部改动文件 `gcc -Wall -Wextra`(core + app + tests,-Wpedantic 对 ncm_tag) | **0 warning** | +| `build_ncm.sh` / `build_core.sh` / `build_ncm_host.sh` 源列表 | 已加入 `core/ncm_tag.c`、`app/ui/cache.c`(交叉/宿主编译本机缺工具链与 vendor,未执行) | + +### 38.6 诚实出入 / 遗留 + +- **交叉与整链测试未在本机执行**:worktree 里 `vendor/mbedtls|jsmn|minimp3|qrcodegen` 均被 .gitignore 排除且 + 本机无交叉工具链(`libs_common.sh` 指向 /home/Aska/Project/R3ProII/.tools)。故 `build_core.sh`/ + `build_ncm.sh`/`build_ncm_host.sh` 与本仓库 core 测试**未运行**;已用真实源码单独编译(0 warning)并跑 + ncm_tag 全量与临时 cache-scan 单测覆盖。**需在构建环境补跑 `build_core.sh` 的 host+mipsel 两套测试**。 +- **未上真机**:不碰真机 Music/MeowData(红线)。ta 嵌入后「外部播放器可读」「minimp3/dr_flac 读带 tag 的 + 缓存文件仍正常」需真机抽验;minimp3_ex 对 ID3v2 有跳过逻辑(VENDOR_NOTES:「minimp3_ex 带 ID3/CRC 处理」), + dr_flac 原生解析 VORBIS_COMMENT 元数据块,理论兼容,但未实测。 +- **tag 重写是一次额外整文件读+写**(TF 顺序 IO):在下载线程内、播放收尾前完成;短曲/快速拖动到底时 + dispose 的 join 可能多等 ~0.3–1s。磁盘瞬时要 2× 单曲空间(失败自动降级裸流)。 +- **缓存队列点播的时长/封面**取自旁路 `meta/.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。 +- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。 diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 9a13716..ad3d874 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -349,6 +349,11 @@ typedef struct { char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */ size_t maxb; /* 下载限幅 */ int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */ + /* 缓存文件元数据嵌入(NCM_CACHE_DESIGN §2.5 + M5):下载完整后由下载线程把 + * id3/vorbis tag 写进 .tmp(按实际容器分支),再交播放线程 commit。失败只记日志, + * commit 裸流——播放主链路不受影响。 */ + ncm_tag_meta_t tag; + char ext[8]; /* 实际容器(song_url type / url 扩展名) */ /* 阶段3 .part 续传(§5.2) */ uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=- */ int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */ @@ -441,8 +446,16 @@ static void *dl_thread(void *arg) if (!ok_st) rc = NCM_ERR_HTTP; } ss->dl_rc = rc; - /* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */ + /* 缓存文件元数据嵌入(M5):下载完整 → 把 id3/vorbis tag 流式写进 .tmp(64KB 分块, + * 同 inode 替换:解码端持有旧 fd 继续播,不受影响)。先 finish 让读端自然收尾,再重写; + * dispose 的 join 保证「重写完成 → commit」顺序。任何失败都只降级为裸流。 */ stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc); + if (rc == NCM_OK && ss->w && !g_app_quit) { + int trc = ncm_tag_embed_file(ncm_cache_writer_tmp_path(ss->w), ss->ext, &ss->tag); + if (trc != NCM_OK) + meow_log(MEOW_LOG_WARN, "play: tag embed skipped rc=%d ext=%s (raw stream kept)", + trc, ss->ext); + } return NULL; } @@ -548,6 +561,12 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch const char *ext = "mp3"; if (ncm_streq(u.type, "flac")) ext = "flac"; else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac"; + ncm_strlcpy(ss->ext, ext, sizeof ss->ext); + /* 文件内 tag 字段(下载完整后由 dl_thread 写入;空字段不写)。 */ + memset(&ss->tag, 0, sizeof ss->tag); + ncm_strlcpy(ss->tag.title, s->name, sizeof ss->tag.title); + ncm_strlcpy(ss->tag.artist, s->art_name, sizeof ss->tag.artist); + ncm_strlcpy(ss->tag.album, s->album_name, sizeof ss->tag.album); /* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */ ncm_cache_evict(&g_st.ctx.cache); diff --git a/patch_v2/ncm_app/test/test_ncm_core.c b/patch_v2/ncm_app/test/test_ncm_core.c index 18477b5..396037f 100644 --- a/patch_v2/ncm_app/test/test_ncm_core.c +++ b/patch_v2/ncm_app/test/test_ncm_core.c @@ -168,6 +168,143 @@ static void test_cache(void) { } } +/* ---------- M5:缓存文件内元数据嵌入 + 已缓存列表扫描 ---------- + * 样本全手工构造(不引第三方库):FLAC = "fLaC" + STREAMINFO(34B) + 假帧;MP3 = 帧同步 + 假帧。 + * 断言链路:writer 写裸流 → ncm_tag_embed_file 嵌 tag → ncm_tag_read_file 读回 → + * ncm_cache_scan 列出(名称来自文件内 tag、档位=实际档)→ ncm_cache_remove/clear。 */ +static void make_flac_bytes(unsigned char *buf, size_t *len, int bps) +{ + size_t o = 0; + memset(buf, 0, 34 + 12 + 512); + memcpy(buf + o, "fLaC", 4); o += 4; + buf[o++] = 0x80; buf[o++] = 0x00; buf[o++] = 0x00; buf[o++] = 34; /* STREAMINFO, last */ + unsigned char *si = buf + o; + si[0] = 0x10; si[1] = 0x00; si[2] = 0x10; si[3] = 0x00; + si[10] = 0x0A; si[11] = 0xC4; /* 44100 */ + si[12] = (unsigned char)(0x40 | 0x02 | (((bps - 1) >> 4) & 0x01)); /* 2ch + bps 高位 */ + si[13] = (unsigned char)(((bps - 1) & 0x0F) << 4); + si[16] = 0xAC; si[17] = 0x44; /* total samples */ + o += 34; + for (int i = 0; i < 512; i++) buf[o++] = (unsigned char)(0xF8 ^ i); + *len = o; +} + +static void test_cache_tag(void) { + printf("== cache tag embed + scan (M5) ==\n"); + ncm_cache_t c; + char dir[] = "/tmp/ncm_tag_cache"; + { + char cmd[160]; + snprintf(cmd, sizeof cmd, "rm -rf %s; mkdir -p %s", dir, dir); + if (system(cmd) != 0) { /* best-effort */ } + } + ncm_cache_init(&c, dir); + + /* ① MP3 条目:文件名带请求档 hires、实际是 320kbps mp3(真机 song__hires.mp3 形态) */ + { + unsigned char mp3[600]; + memset(mp3, 0x11, sizeof mp3); + mp3[0] = 0xFF; mp3[1] = 0xFB; mp3[2] = 0xE0; mp3[3] = 0x00; /* 320kbps / 44.1kHz */ + ncm_cache_writer_t *w; + CHECKRC(ncm_cache_open(&c, "171650_hires", "mp3", &w), "open cache entry (hires name/mp3 body)"); + CHECKRC(ncm_cache_write(w, mp3, sizeof mp3), "write raw mp3"); + CHECKRC(ncm_cache_commit(w), "commit"); + + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path), "lookup entry"); + ncm_tag_meta_t m, got; + memset(&m, 0, sizeof m); + snprintf(m.title, sizeof m.title, "晴天"); + snprintf(m.artist, sizeof m.artist, "周杰伦"); + snprintf(m.album, sizeof m.album, "叶惠美"); + CHECKRC(ncm_tag_embed_file(path, "mp3", &m), "embed ID3v2.3"); + memset(&got, 0, sizeof got); + CHECKRC(ncm_tag_read_file(path, &got), "read back embedded tag"); + CHECK(strcmp(got.title, "晴天") == 0 && strcmp(got.artist, "周杰伦") == 0 && + strcmp(got.album, "叶惠美") == 0, + "tag roundtrip (title=%s artist=%s)", got.title, got.artist); + struct stat st; + CHECK(stat(path, &st) == 0 && st.st_size > (off_t)sizeof mp3, + "file grew by tag bytes (%ld > %zu)", (long)st.st_size, sizeof mp3); + ncm_audio_info_t ai; + CHECK(ncm_audio_probe_file(path, &ai) == NCM_OK && + strcmp(ai.container, "mp3") == 0 && ai.bitrate_kbps == 320, + "probe actual container after tagging (%s %dkbps)", ai.container, ai.bitrate_kbps); + } + + /* ② FLAC 条目:24bit/44.1k → 实际档应判 Hi-Res(请求档写 hires 亦一致) */ + { + unsigned char fl[700]; + size_t flen = 0; + make_flac_bytes(fl, &flen, 24); + ncm_cache_writer_t *w; + CHECKRC(ncm_cache_open(&c, "2001_hires", "flac", &w), "open flac cache entry"); + CHECKRC(ncm_cache_write(w, fl, flen), "write raw flac"); + CHECKRC(ncm_cache_commit(w), "commit flac"); + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_lookup(&c, "2001_hires", "flac", path, sizeof path), "lookup flac entry"); + ncm_tag_meta_t m, got; + memset(&m, 0, sizeof m); + snprintf(m.title, sizeof m.title, "夜曲"); + snprintf(m.artist, sizeof m.artist, "周杰伦"); + CHECKRC(ncm_tag_embed_file(path, "flac", &m), "embed VORBIS_COMMENT"); + memset(&got, 0, sizeof got); + CHECKRC(ncm_tag_read_file(path, &got), "read back flac tag"); + CHECK(strcmp(got.title, "夜曲") == 0, "flac tag roundtrip (title=%s)", got.title); + } + + /* ③ 扫描:两条;名称来自文件内 tag;档位=实际档(mp3 320k → EXHIGH,不显示请求档 hires) */ + { + ncm_cache_item_t items[8]; + int n = ncm_cache_scan(&c, items, 8); + CHECK(n == 2, "scan lists 2 cached entries (got %d)", n); + int seen_mp3 = 0, seen_flac = 0; + for (int i = 0; i < n; i++) { + if (strcmp(items[i].id, "171650") == 0) { + seen_mp3 = 1; + CHECK(strcmp(items[i].level, "hires") == 0, "requested level kept in name (%s)", + items[i].level); + CHECK(items[i].actual_level == NCM_LEVEL_EXHIGH, + "actual level = EXHIGH for 320k mp3 (got %d)", items[i].actual_level); + CHECK(strcmp(items[i].name, "晴天") == 0 && + strcmp(items[i].artist, "周杰伦") == 0, + "scan name/artist from embedded tag (%s/%s)", items[i].name, items[i].artist); + CHECK(strcmp(items[i].ext, "mp3") == 0 && items[i].size > 0, "scan ext/size"); + } else if (strcmp(items[i].id, "2001") == 0) { + seen_flac = 1; + CHECK(items[i].actual_level >= NCM_LEVEL_HIRES, + "actual level = Hi-Res+ for 24bit flac (got %d)", items[i].actual_level); + CHECK(strcmp(items[i].name, "夜曲") == 0, "flac scan name (%s)", items[i].name); + } + } + CHECK(seen_mp3 && seen_flac, "both entries present (mp3=%d flac=%d)", seen_mp3, seen_flac); + } + + /* ④ 单曲删除 + LRU 淘汰(带 tag 文件照常计入池)+ 清空 */ + { + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_remove(&c, "171650", "hires", "mp3"), "remove single entry"); + CHECK(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path) == NCM_ERR_NOENT, + "removed entry gone"); + ncm_cache_item_t items[8]; + CHECK(ncm_cache_scan(&c, items, 8) == 1, "scan after remove -> 1"); + /* 兼容性:嵌入 tag 的文件照常计入 LRU 池、按 mtime 被淘汰(旧无 tag 条目同理) */ + CHECK(ncm_cache_total_bytes(&c) > 0, "tagged entry counted in LRU pool"); + c.limit_bytes = 1; /* 任何条目都超限 → 必淘汰 */ + CHECKRC(ncm_cache_evict(&c), "evict with tagged entry"); + CHECK(ncm_cache_scan(&c, items, 8) == 0, "tagged entry evicted by LRU"); + ncm_cache_set_limit_gb(&c, 10); + { + ncm_cache_writer_t *w2; + ncm_cache_open(&c, "3001_exhigh", "mp3", &w2); + ncm_cache_write(w2, (const unsigned char *)"X", 1); + ncm_cache_commit(w2); + } + CHECKRC(ncm_cache_clear(&c), "clear cache pool"); + CHECK(ncm_cache_scan(&c, items, 8) == 0, "scan after clear -> 0"); + } +} + static void test_qr(void) { printf("== qrcodegen ==\n"); unsigned char mods[177 * 177]; @@ -541,6 +678,7 @@ int main(int argc, char **argv) { test_crypto_vectors(); test_store_cookie(); test_cache(); + test_cache_tag(); test_qr(); test_cookie_merge(); test_eapi_musicu_hdr();