Merge branch 'feat/ncm-cached-list-and-tag-embed'
This commit is contained in:
commit
a73bcb9841
24 files changed
+2349
-21
No files matched your search
@@ -3293,3 +3293,79 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进
|
|||||||
- **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。
|
- **`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` 域不受影响)。
|
- **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。
|
||||||
- **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。
|
- **未部署**:`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),不能按名字判容器。
|
||||||
|
- 写入 = 同目录 `<path>.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/续传字节语义零改动。
|
||||||
|
- **元数据分离体系不动**:`<cache_dir>/meta/<id>.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/<id>.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。
|
||||||
|
- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。
|
||||||
@@ -24,7 +24,7 @@ volatile int g_net_ready = 0;
|
|||||||
int g_host_mock = 0;
|
int g_host_mock = 0;
|
||||||
|
|
||||||
static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view,
|
static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view,
|
||||||
*g_search_view, *g_queue_view;
|
*g_search_view, *g_queue_view, *g_cache_view;
|
||||||
|
|
||||||
static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); }
|
static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); }
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ static void app_on_tap(int x, int y)
|
|||||||
if (settings_is_active()) { settings_on_tap(x, y); return; }
|
if (settings_is_active()) { settings_on_tap(x, y); return; }
|
||||||
if (playlist_is_active()) { playlist_on_tap(x, y); return; }
|
if (playlist_is_active()) { playlist_on_tap(x, y); return; }
|
||||||
if (queue_is_active()) { queue_on_tap(x, y); return; }
|
if (queue_is_active()) { queue_on_tap(x, y); return; }
|
||||||
|
if (cache_is_active()) { cache_on_tap(x, y); return; }
|
||||||
if (search_is_active()) { search_on_tap(x, y); return; }
|
if (search_is_active()) { search_on_tap(x, y); return; }
|
||||||
home_on_tap(x, y);
|
home_on_tap(x, y);
|
||||||
}
|
}
|
||||||
@@ -107,6 +108,7 @@ static void app_on_swipe(int dir, int dx, int dy)
|
|||||||
if (settings_is_active()) { meow_view_page_pop(); return; }
|
if (settings_is_active()) { meow_view_page_pop(); return; }
|
||||||
if (playlist_is_active()) { meow_view_page_pop(); return; }
|
if (playlist_is_active()) { meow_view_page_pop(); return; }
|
||||||
if (queue_is_active()) { meow_view_page_pop(); return; }
|
if (queue_is_active()) { meow_view_page_pop(); return; }
|
||||||
|
if (cache_is_active()) { meow_view_page_pop(); return; }
|
||||||
if (search_is_active()) { meow_view_page_pop(); return; }
|
if (search_is_active()) { meow_view_page_pop(); return; }
|
||||||
if (home_is_active()) {
|
if (home_is_active()) {
|
||||||
/* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。
|
/* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。
|
||||||
@@ -124,6 +126,7 @@ static void app_on_swipe(int dir, int dx, int dy)
|
|||||||
if (home_is_active()) { home_swipe(dir, dy); return; }
|
if (home_is_active()) { home_swipe(dir, dy); return; }
|
||||||
if (playlist_is_active()) { playlist_swipe(dir, dy); return; }
|
if (playlist_is_active()) { playlist_swipe(dir, dy); return; }
|
||||||
if (queue_is_active()) { queue_swipe(dir, dy); return; }
|
if (queue_is_active()) { queue_swipe(dir, dy); return; }
|
||||||
|
if (cache_is_active()) { cache_swipe(dir, dy); return; }
|
||||||
if (search_is_active()) { search_swipe(dir, dy); return; }
|
if (search_is_active()) { search_swipe(dir, dy); return; }
|
||||||
if (player_is_active()) { player_on_swipe(dir, dy); return; }
|
if (player_is_active()) { player_on_swipe(dir, dy); return; }
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,7 @@ static void app_on_touch_down(int x, int y)
|
|||||||
else if (home_is_active()) home_touch_down(x, y);
|
else if (home_is_active()) home_touch_down(x, y);
|
||||||
else if (playlist_is_active()) playlist_touch_down(x, y);
|
else if (playlist_is_active()) playlist_touch_down(x, y);
|
||||||
else if (queue_is_active()) queue_touch_down(x, y);
|
else if (queue_is_active()) queue_touch_down(x, y);
|
||||||
|
else if (cache_is_active()) cache_touch_down(x, y);
|
||||||
else if (settings_is_active()) settings_touch_down(x, y);
|
else if (settings_is_active()) settings_touch_down(x, y);
|
||||||
else if (search_is_active()) search_touch_down(x, y);
|
else if (search_is_active()) search_touch_down(x, y);
|
||||||
}
|
}
|
||||||
@@ -144,6 +148,7 @@ static void app_on_touch_move(int x, int y)
|
|||||||
else if (home_is_active()) home_touch_move(x, y);
|
else if (home_is_active()) home_touch_move(x, y);
|
||||||
else if (playlist_is_active()) playlist_touch_move(x, y);
|
else if (playlist_is_active()) playlist_touch_move(x, y);
|
||||||
else if (queue_is_active()) queue_touch_move(x, y);
|
else if (queue_is_active()) queue_touch_move(x, y);
|
||||||
|
else if (cache_is_active()) cache_touch_move(x, y);
|
||||||
else if (settings_is_active()) settings_touch_move(x, y);
|
else if (settings_is_active()) settings_touch_move(x, y);
|
||||||
else if (search_is_active()) search_touch_move(x, y);
|
else if (search_is_active()) search_touch_move(x, y);
|
||||||
}
|
}
|
||||||
@@ -194,6 +199,7 @@ static void app_on_tick(void)
|
|||||||
if (player_is_active()) { player_tick(now); return; }
|
if (player_is_active()) { player_tick(now); return; }
|
||||||
if (search_is_active()) { search_tick(now); return; }
|
if (search_is_active()) { search_tick(now); return; }
|
||||||
if (queue_is_active()) { queue_tick(now); return; }
|
if (queue_is_active()) { queue_tick(now); return; }
|
||||||
|
if (cache_is_active()) { cache_tick(now); return; }
|
||||||
if (home_is_active()) { home_tick(now); }
|
if (home_is_active()) { home_tick(now); }
|
||||||
if (playlist_is_active()) { playlist_tick(now); }
|
if (playlist_is_active()) { playlist_tick(now); }
|
||||||
chrome_tick();
|
chrome_tick();
|
||||||
@@ -233,6 +239,7 @@ int main(void)
|
|||||||
g_set_view = settings_view_create();
|
g_set_view = settings_view_create();
|
||||||
g_search_view = search_view_create();
|
g_search_view = search_view_create();
|
||||||
g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */
|
g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */
|
||||||
|
g_cache_view = cache_view_create(); /* M5 已缓存列表页 */
|
||||||
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
|
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
|
||||||
|
|
||||||
/* 进入根页:已登录→home,否则→login */
|
/* 进入根页:已登录→home,否则→login */
|
||||||
@@ -256,6 +263,7 @@ int main(void)
|
|||||||
meow_view_free(g_login_view);
|
meow_view_free(g_login_view);
|
||||||
meow_view_free(g_search_view);
|
meow_view_free(g_search_view);
|
||||||
meow_view_free(g_queue_view);
|
meow_view_free(g_queue_view);
|
||||||
|
meow_view_free(g_cache_view);
|
||||||
meow_ui_shutdown();
|
meow_ui_shutdown();
|
||||||
meow_log(MEOW_LOG_INFO, "exit");
|
meow_log(MEOW_LOG_INFO, "exit");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -783,6 +783,7 @@ void appstate_apply_play_mode(int m)
|
|||||||
int appstate_queue_count(void)
|
int appstate_queue_count(void)
|
||||||
{
|
{
|
||||||
int c;
|
int c;
|
||||||
|
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_count();
|
||||||
pthread_mutex_lock(&g_search_mu);
|
pthread_mutex_lock(&g_search_mu);
|
||||||
c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count;
|
c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count;
|
||||||
pthread_mutex_unlock(&g_search_mu);
|
pthread_mutex_unlock(&g_search_mu);
|
||||||
@@ -792,6 +793,7 @@ int appstate_queue_count(void)
|
|||||||
int appstate_queue_song(int idx, ncm_song_t *out)
|
int appstate_queue_song(int idx, ncm_song_t *out)
|
||||||
{
|
{
|
||||||
if (!out) return NCM_ERR_ARG;
|
if (!out) return NCM_ERR_ARG;
|
||||||
|
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_song(idx, out);
|
||||||
if (g_st.queue_src == NCM_QUEUE_SEARCH) {
|
if (g_st.queue_src == NCM_QUEUE_SEARCH) {
|
||||||
pthread_mutex_lock(&g_search_mu);
|
pthread_mutex_lock(&g_search_mu);
|
||||||
int ok = (idx >= 0 && idx < g_st.search_song_count);
|
int ok = (idx >= 0 && idx < g_st.search_song_count);
|
||||||
@@ -803,6 +805,105 @@ int appstate_queue_song(int idx, ncm_song_t *out)
|
|||||||
return appstate_song_fetch(idx, out);
|
return appstate_song_fetch(idx, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 已缓存列表(ACT_CACHE / NCM_QUEUE_CACHE;数据源 = songs/ 池 + 文件内 tag)
|
||||||
|
* 数组读写经 g_cache_mu;扫描走 worker(磁盘 IO 不进 UI 线程)。
|
||||||
|
* ============================================================ */
|
||||||
|
static pthread_mutex_t g_cache_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
|
||||||
|
int appstate_cache_list_load(void)
|
||||||
|
{
|
||||||
|
/* 堆上临时数组(≈116KB):扫描期间不持锁,UI/播放线程读旧快照不被 IO 阻塞。 */
|
||||||
|
ncm_cache_item_t *tmp = (ncm_cache_item_t *)malloc(sizeof(ncm_cache_item_t) * NCM_CACHE_LIST_MAX);
|
||||||
|
if (!tmp) return NCM_ERR_OOM;
|
||||||
|
int n = ncm_cache_scan(&g_st.ctx.cache, tmp, NCM_CACHE_LIST_MAX);
|
||||||
|
if (n < 0) { free(tmp); return n; }
|
||||||
|
/* 旧无 tag 缓存:回退旁路元数据(songmeta)补名称/歌手,保持列表可读。 */
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
ncm_song_t s;
|
||||||
|
if (tmp[i].name[0] && tmp[i].artist[0]) continue;
|
||||||
|
if (songmeta_load(g_st.cache_dir, tmp[i].id, &s) != NCM_OK) continue;
|
||||||
|
if (!tmp[i].name[0]) ncm_strlcpy(tmp[i].name, s.name, sizeof tmp[i].name);
|
||||||
|
if (!tmp[i].artist[0]) ncm_strlcpy(tmp[i].artist, s.art_name, sizeof tmp[i].artist);
|
||||||
|
}
|
||||||
|
pthread_mutex_lock(&g_cache_mu);
|
||||||
|
memcpy(g_st.cache_items, tmp, sizeof(ncm_cache_item_t) * (size_t)n);
|
||||||
|
g_st.cache_item_count = n;
|
||||||
|
pthread_mutex_unlock(&g_cache_mu);
|
||||||
|
free(tmp);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int appstate_cache_count(void)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
pthread_mutex_lock(&g_cache_mu);
|
||||||
|
n = g_st.cache_item_count;
|
||||||
|
pthread_mutex_unlock(&g_cache_mu);
|
||||||
|
return n < 0 ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int appstate_cache_item(int i, ncm_cache_item_t *out)
|
||||||
|
{
|
||||||
|
if (!out) return NCM_ERR_ARG;
|
||||||
|
pthread_mutex_lock(&g_cache_mu);
|
||||||
|
int ok = (i >= 0 && i < g_st.cache_item_count);
|
||||||
|
if (ok) *out = g_st.cache_items[i];
|
||||||
|
pthread_mutex_unlock(&g_cache_mu);
|
||||||
|
return ok ? NCM_OK : NCM_ERR_ARG;
|
||||||
|
}
|
||||||
|
|
||||||
|
int appstate_cache_song(int i, ncm_song_t *out)
|
||||||
|
{
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
ncm_song_t m;
|
||||||
|
if (!out) return NCM_ERR_ARG;
|
||||||
|
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
|
||||||
|
memset(out, 0, sizeof *out);
|
||||||
|
ncm_strlcpy(out->id, it.id, sizeof out->id);
|
||||||
|
ncm_strlcpy(out->name, it.name[0] ? it.name : it.id, sizeof out->name);
|
||||||
|
ncm_strlcpy(out->art_name, it.artist, sizeof out->art_name);
|
||||||
|
/* 旁路元数据(≤600B 单次读)补时长/封面/fee;播放页与上/下一首逻辑照旧可用。 */
|
||||||
|
if (songmeta_load(g_st.cache_dir, it.id, &m) == NCM_OK) {
|
||||||
|
if (!it.name[0] && m.name[0]) ncm_strlcpy(out->name, m.name, sizeof out->name);
|
||||||
|
if (!it.artist[0] && m.art_name[0]) ncm_strlcpy(out->art_name, m.art_name, sizeof out->art_name);
|
||||||
|
out->duration_ms = m.duration_ms;
|
||||||
|
out->fee = m.fee;
|
||||||
|
ncm_strlcpy(out->al_pic, m.al_pic, sizeof out->al_pic);
|
||||||
|
}
|
||||||
|
return NCM_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int appstate_cache_remove(int i)
|
||||||
|
{
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
int rc;
|
||||||
|
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
|
||||||
|
/* 条目可能已被 LRU/清空删掉:NOENT 也算「已不在」,照样摘除列表项。 */
|
||||||
|
rc = ncm_cache_remove(&g_st.ctx.cache, it.id, it.level, it.ext);
|
||||||
|
if (rc != NCM_OK && rc != NCM_ERR_NOENT) return rc;
|
||||||
|
pthread_mutex_lock(&g_cache_mu);
|
||||||
|
for (int k = 0; k < g_st.cache_item_count; k++) {
|
||||||
|
if (strcmp(g_st.cache_items[k].id, it.id) != 0 ||
|
||||||
|
strcmp(g_st.cache_items[k].level, it.level) != 0 ||
|
||||||
|
strcmp(g_st.cache_items[k].ext, it.ext) != 0) continue;
|
||||||
|
memmove(&g_st.cache_items[k], &g_st.cache_items[k + 1],
|
||||||
|
sizeof(ncm_cache_item_t) * (size_t)(g_st.cache_item_count - k - 1));
|
||||||
|
g_st.cache_item_count--;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_cache_mu);
|
||||||
|
return NCM_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appstate_cache_clear(void)
|
||||||
|
{
|
||||||
|
ncm_cache_clear(&g_st.ctx.cache); /* songs/ + covers/ 的 song_*(设置页同语义) */
|
||||||
|
pthread_mutex_lock(&g_cache_mu);
|
||||||
|
g_st.cache_item_count = 0;
|
||||||
|
pthread_mutex_unlock(&g_cache_mu);
|
||||||
|
}
|
||||||
|
|
||||||
int appstate_search_count(int tab)
|
int appstate_search_count(int tab)
|
||||||
{
|
{
|
||||||
int c;
|
int c;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
#define ACT_SETTINGS 4 /* 设置页 */
|
#define ACT_SETTINGS 4 /* 设置页 */
|
||||||
#define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */
|
#define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */
|
||||||
#define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */
|
#define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */
|
||||||
|
#define ACT_CACHE 7 /* 已缓存列表页(首页顶栏「已缓存」入口,NCM_CACHE_DESIGN §2.5) */
|
||||||
|
|
||||||
/* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)----
|
/* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)----
|
||||||
* 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。
|
* 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。
|
||||||
@@ -115,6 +116,11 @@ static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; }
|
|||||||
/* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */
|
/* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */
|
||||||
#define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */
|
#define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */
|
||||||
#define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */
|
#define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */
|
||||||
|
#define NCM_QUEUE_CACHE 2 /* 已缓存列表队列(点播缓存页条目;上/下一首在缓存列表内循环) */
|
||||||
|
|
||||||
|
/* 已缓存列表条目上限(NCM_CACHE_LIST_MAX 条 × sizeof(ncm_cache_item_t)≈232B ≈ 116KB bss)。
|
||||||
|
* 缓存池条目更多时只展示最近访问的 max 条(scan 按 mtime 降序保留最近的)。 */
|
||||||
|
#define NCM_CACHE_LIST_MAX 512
|
||||||
|
|
||||||
/* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */
|
/* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */
|
||||||
#define NCM_PL_SEARCH (-2)
|
#define NCM_PL_SEARCH (-2)
|
||||||
@@ -269,6 +275,10 @@ typedef struct ncm_app_state {
|
|||||||
|
|
||||||
/* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */
|
/* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */
|
||||||
int queue_src;
|
int queue_src;
|
||||||
|
|
||||||
|
/* 已缓存列表(ACT_CACHE 页 + NCM_QUEUE_CACHE 队列;worker 扫描磁盘填充,UI 只读) */
|
||||||
|
ncm_cache_item_t cache_items[NCM_CACHE_LIST_MAX];
|
||||||
|
int cache_item_count;
|
||||||
} ncm_app_state_t;
|
} ncm_app_state_t;
|
||||||
|
|
||||||
extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */
|
extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */
|
||||||
@@ -353,6 +363,7 @@ void player_tick(uint64_t now); /* 播放进度/歌词滚动 */
|
|||||||
void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */
|
void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */
|
||||||
void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */
|
void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */
|
||||||
void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */
|
void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */
|
||||||
|
void player_play_cache_song(int i); /* 从已缓存列表第 i 首点播(切 NCM_QUEUE_CACHE 队列) */
|
||||||
void player_pause_toggle(int on);
|
void player_pause_toggle(int on);
|
||||||
void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */
|
void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */
|
||||||
void player_seek(int64_t ms);
|
void player_seek(int64_t ms);
|
||||||
@@ -390,6 +401,19 @@ void queue_touch_move(int x, int y);
|
|||||||
void queue_open(void); /* 进当前播放队列页 */
|
void queue_open(void); /* 进当前播放队列页 */
|
||||||
void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */
|
void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */
|
||||||
|
|
||||||
|
/* cache(已缓存列表页,ACT_CACHE:首页顶栏「已缓存」钮进入,NCM_CACHE_DESIGN §2.5)
|
||||||
|
* 列表 = songs/ 池扫描(worker 读盘,UI 零 IO);行显示 歌名/歌手/实际档位/大小;
|
||||||
|
* 点行=点播(NCM_QUEUE_CACHE),行右「删除」=单曲删除,顶栏「清空」=清空缓存池。 */
|
||||||
|
meow_view *cache_view_create(void);
|
||||||
|
int cache_is_active(void);
|
||||||
|
int cache_on_tap(int x, int y);
|
||||||
|
void cache_swipe(int dir, int dist);
|
||||||
|
void cache_touch_down(int x, int y);
|
||||||
|
void cache_touch_move(int x, int y);
|
||||||
|
void cache_open(void); /* 进已缓存列表页(push ACT_CACHE) */
|
||||||
|
void cache_reload(void); /* 起 worker 重扫缓存池(非阻塞) */
|
||||||
|
void cache_tick(uint64_t now); /* UI tick:检查扫描 worker 完成并刷新 */
|
||||||
|
|
||||||
/* search */
|
/* search */
|
||||||
meow_view *search_view_create(void);
|
meow_view *search_view_create(void);
|
||||||
int search_is_active(void);
|
int search_is_active(void);
|
||||||
@@ -470,6 +494,16 @@ int appstate_queue_count(void); /* 当前播放队列长度(
|
|||||||
int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */
|
int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */
|
||||||
void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */
|
void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */
|
||||||
|
|
||||||
|
/* ---- 已缓存列表(NCM_CACHE_LIST_MAX;磁盘扫描/删除只在 worker 或 UI 单次小 IO)----
|
||||||
|
* queue_src==NCM_QUEUE_CACHE 时,appstate_queue_count/song 走本数组(播放页上/下一首同源)。
|
||||||
|
* 名称/歌手优先取文件内 tag(与外部播放器同源),空则回退旁路元数据 <cache_dir>/meta/<id>.bin。 */
|
||||||
|
int appstate_cache_list_load(void); /* worker:ncm_cache_scan + 旁路补名;返回条数或负错误码 */
|
||||||
|
int appstate_cache_count(void);
|
||||||
|
int appstate_cache_item(int i, ncm_cache_item_t *out); /* 0=ok */
|
||||||
|
int appstate_cache_song(int i, ncm_song_t *out); /* 合成 ncm_song_t(时长/封面/fee 取旁路) */
|
||||||
|
int appstate_cache_remove(int i); /* 删文件 + 摘除条目;0=成功 */
|
||||||
|
void appstate_cache_clear(void); /* 清空缓存池(含封面池,同设置页语义)+ 清列表 */
|
||||||
|
|
||||||
/* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */
|
/* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */
|
||||||
int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */
|
int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */
|
||||||
int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */
|
int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ static const char *ncm_act_name(int act)
|
|||||||
case ACT_PLAYER: return "player";
|
case ACT_PLAYER: return "player";
|
||||||
case ACT_SETTINGS: return "settings";
|
case ACT_SETTINGS: return "settings";
|
||||||
case ACT_SEARCH: return "search";
|
case ACT_SEARCH: return "search";
|
||||||
|
case ACT_CACHE: return "cache"; /* M5 已缓存列表(app.json 未声明常亮 → 照常息屏) */
|
||||||
default: return NULL;
|
default: return NULL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,9 +167,10 @@ void player_manager_set_quality(int level, int cache_gb)
|
|||||||
static int play_is_same_song(int song_idx)
|
static int play_is_same_song(int song_idx)
|
||||||
{
|
{
|
||||||
ncm_song_t cand;
|
ncm_song_t cand;
|
||||||
int have = (appstate_get_queue() == NCM_QUEUE_SEARCH)
|
int src = appstate_get_queue();
|
||||||
? (appstate_search_song(song_idx, &cand) == NCM_OK)
|
int have = (src == NCM_QUEUE_SEARCH) ? (appstate_search_song(song_idx, &cand) == NCM_OK)
|
||||||
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
|
: (src == NCM_QUEUE_CACHE) ? (appstate_cache_song(song_idx, &cand) == NCM_OK)
|
||||||
|
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
|
||||||
if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */
|
if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */
|
||||||
pthread_mutex_lock(&g_play.mu);
|
pthread_mutex_lock(&g_play.mu);
|
||||||
int same = g_play.play_song_valid && g_play.play_song.id[0] &&
|
int same = g_play.play_song_valid && g_play.play_song.id[0] &&
|
||||||
@@ -348,6 +349,11 @@ typedef struct {
|
|||||||
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
|
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
|
||||||
size_t maxb; /* 下载限幅 */
|
size_t maxb; /* 下载限幅 */
|
||||||
int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */
|
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) */
|
/* 阶段3 .part 续传(§5.2) */
|
||||||
uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=<n>- */
|
uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=<n>- */
|
||||||
int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */
|
int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */
|
||||||
@@ -440,8 +446,16 @@ static void *dl_thread(void *arg)
|
|||||||
if (!ok_st) rc = NCM_ERR_HTTP;
|
if (!ok_st) rc = NCM_ERR_HTTP;
|
||||||
}
|
}
|
||||||
ss->dl_rc = rc;
|
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);
|
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;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +561,12 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
|
|||||||
const char *ext = "mp3";
|
const char *ext = "mp3";
|
||||||
if (ncm_streq(u.type, "flac")) ext = "flac";
|
if (ncm_streq(u.type, "flac")) ext = "flac";
|
||||||
else if (!u.type[0] && strstr(u.url, ".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 跳过,无在途误删风险) */
|
/* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */
|
||||||
ncm_cache_evict(&g_st.ctx.cache);
|
ncm_cache_evict(&g_st.ctx.cache);
|
||||||
@@ -645,6 +665,31 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
|
|||||||
char key[64];
|
char key[64];
|
||||||
audio_cache_key(key, sizeof key, id, g_level);
|
audio_cache_key(key, sizeof key, id, g_level);
|
||||||
|
|
||||||
|
/* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名请求档)与实际扩展名直接
|
||||||
|
* 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」
|
||||||
|
* 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */
|
||||||
|
if (appstate_get_queue() == NCM_QUEUE_CACHE) {
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
if (appstate_cache_item(idx, &it) == NCM_OK) {
|
||||||
|
char ck[48];
|
||||||
|
if (it.level[0]) snprintf(ck, sizeof ck, "%s_%s", it.id, it.level);
|
||||||
|
else ncm_strlcpy(ck, it.id, sizeof ck);
|
||||||
|
if (ncm_cache_lookup(&g_st.ctx.cache, ck, it.ext, path, psz) == NCM_OK) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(path, &st) == 0 &&
|
||||||
|
stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) == 0) {
|
||||||
|
ss->total = (int64_t)st.st_size;
|
||||||
|
*bytes = ss->total;
|
||||||
|
pthread_mutex_lock(&g_play.mu);
|
||||||
|
g_play.eff_level = it.actual_level; /* 实际档(非请求档),UI 标注用 */
|
||||||
|
pthread_mutex_unlock(&g_play.mu);
|
||||||
|
return NCM_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NCM_ERR_NOENT;
|
||||||
|
}
|
||||||
|
|
||||||
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
|
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
|
||||||
int hit = 0;
|
int hit = 0;
|
||||||
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
|
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
|
||||||
|
|||||||
@@ -72,6 +72,37 @@ static void mock_lyric(void)
|
|||||||
lyric_parse(lrc, tl);
|
lyric_parse(lrc, tl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* M5:已缓存列表页 mock —— 造 3 条 songs/ 缓存(mp3 帧 + 嵌入 ID3v2.3),再同步扫描进快照。 */
|
||||||
|
static void mock_cache(void)
|
||||||
|
{
|
||||||
|
static const char *names[] = { "Song Cached One", "Song Cached Two", "Song Cached Three" };
|
||||||
|
static const char *arts[] = { "Artist X", "Artist Y", "Artist Z" };
|
||||||
|
static const char *lvks[] = { "exhigh", "hires", "lossless" };
|
||||||
|
ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir);
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
char id[24], key[48];
|
||||||
|
unsigned char fr[4096];
|
||||||
|
ncm_cache_writer_t *w;
|
||||||
|
snprintf(id, sizeof id, "%ld", 5000L + i);
|
||||||
|
snprintf(key, sizeof key, "%s_%s", id, lvks[i]);
|
||||||
|
for (size_t k = 0; k < sizeof fr; k++) fr[k] = (unsigned char)(0x20 + (k * 7));
|
||||||
|
fr[0] = 0xFF; fr[1] = 0xFB; fr[2] = 0xE0; fr[3] = 0x00; /* 320kbps / 44.1kHz */
|
||||||
|
if (ncm_cache_open(&g_st.ctx.cache, key, "mp3", &w) != NCM_OK) continue;
|
||||||
|
ncm_cache_write(w, fr, sizeof fr);
|
||||||
|
if (ncm_cache_commit(w) != NCM_OK) continue;
|
||||||
|
char path[NCM_CACHE_DIR_MAX + 64];
|
||||||
|
if (ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, sizeof path) == NCM_OK) {
|
||||||
|
ncm_tag_meta_t t;
|
||||||
|
memset(&t, 0, sizeof t);
|
||||||
|
snprintf(t.title, sizeof t.title, "%s", names[i]);
|
||||||
|
snprintf(t.artist, sizeof t.artist, "%s", arts[i]);
|
||||||
|
snprintf(t.album, sizeof t.album, "Album %d", i + 1);
|
||||||
|
ncm_tag_embed_file(path, "mp3", &t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appstate_cache_list_load();
|
||||||
|
}
|
||||||
|
|
||||||
static void mock_qr(void)
|
static void mock_qr(void)
|
||||||
{
|
{
|
||||||
g_st.qr_size = 29;
|
g_st.qr_size = 29;
|
||||||
@@ -137,6 +168,7 @@ static int page_id(const char *name)
|
|||||||
if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */
|
if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */
|
||||||
if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */
|
if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */
|
||||||
if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */
|
if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */
|
||||||
|
if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) return ACT_CACHE; /* M5 */
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,8 +300,7 @@ int main(int argc, char **argv)
|
|||||||
appstate_search_history_add("Jay \u5553\u8868");
|
appstate_search_history_add("Jay \u5553\u8868");
|
||||||
appstate_search_history_add("lofi beat");
|
appstate_search_history_add("lofi beat");
|
||||||
appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */
|
appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */
|
||||||
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) {
|
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
|
||||||
/* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
|
|
||||||
mock_playlists(); mock_songs();
|
mock_playlists(); mock_songs();
|
||||||
pthread_mutex_lock(&g_play.mu);
|
pthread_mutex_lock(&g_play.mu);
|
||||||
g_play.state = NCM_PC_PLAYING;
|
g_play.state = NCM_PC_PLAYING;
|
||||||
@@ -290,6 +321,8 @@ int main(int argc, char **argv)
|
|||||||
g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */
|
g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */
|
||||||
g_play.play_song_valid = 1;
|
g_play.play_song_valid = 1;
|
||||||
pthread_mutex_unlock(&g_play.mu);
|
pthread_mutex_unlock(&g_play.mu);
|
||||||
|
} else if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) {
|
||||||
|
mock_cache(); /* M5:假缓存池 + 文件内 tag + 快照扫描 */
|
||||||
} else {
|
} else {
|
||||||
mock_playlists();
|
mock_playlists();
|
||||||
}
|
}
|
||||||
@@ -301,6 +334,7 @@ int main(int argc, char **argv)
|
|||||||
strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create();
|
strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create();
|
||||||
else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */
|
else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */
|
||||||
else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */
|
else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */
|
||||||
|
else if (page_id(name) == ACT_CACHE) v = cache_view_create(); /* M5 已缓存列表页 */
|
||||||
else if (strcmp(name, "settings") == 0) v = settings_view_create();
|
else if (strcmp(name, "settings") == 0) v = settings_view_create();
|
||||||
else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 ||
|
else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 ||
|
||||||
strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create();
|
strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create();
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
/* ui/cache.c — 已缓存列表页(ACT_CACHE,首页顶栏「已缓存」钮进入)。
|
||||||
|
*
|
||||||
|
* 数据源:songs/ 音频池(键=歌曲ID+请求档位,NCM_CACHE_DESIGN §2.5)。扫描在 worker:
|
||||||
|
* ncm_cache_scan(读目录 + 文件头 tag + 音频参数探测)→ appstate 快照;
|
||||||
|
* UI 线程只读快照(零磁盘 IO),列表由 meow_listview 虚拟化。
|
||||||
|
* 行:歌名 / 歌手 · **实际档位** · 大小。**档位显示实际档而非请求档**:缓存名里的档位段只是
|
||||||
|
* 请求档(真机存在 song_<id>_hires.mp3 = 无损请求被降级链落到 exhigh mp3),实际档由
|
||||||
|
* 文件头(FLAC bps/采样率、MP3 首帧码率)推断。
|
||||||
|
* 交互:点行=点播(NCM_QUEUE_CACHE,上/下一首在缓存列表内循环);行右「删除」=单曲删除;
|
||||||
|
* 顶栏「清空」=清空缓存池(确认框,与设置页「清理缓存」同语义)。
|
||||||
|
* 主题/控件惯例同 queue.c:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮。
|
||||||
|
* 左缘返回=弹回首页(app.c)。
|
||||||
|
*/
|
||||||
|
#include "../ncm_app_internal.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
|
typedef struct { meow_view *view; int active; } cache_ctrl;
|
||||||
|
static cache_ctrl g_cc;
|
||||||
|
|
||||||
|
static meow_widget *g_tb, *g_list;
|
||||||
|
static ncm_rect_t r_cont, r_list;
|
||||||
|
static int g_bg_id, g_draw_id, g_empty_id, g_footer_id;
|
||||||
|
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
|
||||||
|
static int g_last_count;
|
||||||
|
|
||||||
|
/* 扫描 worker(磁盘 IO 不进 UI 线程;gen 计数做完成信号) */
|
||||||
|
static pthread_mutex_t g_cmu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
static pthread_t g_cthr;
|
||||||
|
static int g_cthr_on;
|
||||||
|
static volatile int g_cgen, g_cacked;
|
||||||
|
static volatile int g_cloading;
|
||||||
|
static volatile int g_cerr;
|
||||||
|
|
||||||
|
static void c_drag_reset(void)
|
||||||
|
{
|
||||||
|
g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 字节数 → "12.3 MB"(整数运算,不引浮点 printf;MIPS 软浮点省体积)。 */
|
||||||
|
static void fmt_size(uint64_t n, char *out, size_t sz)
|
||||||
|
{
|
||||||
|
if (n >= (1024ULL * 1024ULL * 1024ULL)) {
|
||||||
|
uint64_t t = (n * 10ULL + 512ULL * 1024ULL * 1024ULL) / (1024ULL * 1024ULL * 1024ULL);
|
||||||
|
snprintf(out, sz, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10));
|
||||||
|
} else if (n >= 1024ULL * 1024ULL) {
|
||||||
|
uint64_t t = (n * 10ULL + 512ULL * 1024ULL) / (1024ULL * 1024ULL);
|
||||||
|
snprintf(out, sz, "%llu.%llu MB", (unsigned long long)(t / 10), (unsigned long long)(t % 10));
|
||||||
|
} else {
|
||||||
|
snprintf(out, sz, "%llu KB", (unsigned long long)(n / 1024ULL));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *level_label(int lv)
|
||||||
|
{
|
||||||
|
return (lv >= 0) ? ncm_level_name(lv) : "未知档";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表数据源(逐行快照读取,零 IO)。 */
|
||||||
|
static int c_src2(int idx, meow_list_item *out, void *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
static ncm_cache_item_t it;
|
||||||
|
static char sub[192];
|
||||||
|
static char title[100];
|
||||||
|
char sz[24];
|
||||||
|
|
||||||
|
if (appstate_cache_item(idx, &it) != NCM_OK) return 0;
|
||||||
|
fmt_size(it.size, sz, sizeof sz);
|
||||||
|
snprintf(sub, sizeof sub, "%s · %s · %s",
|
||||||
|
it.artist[0] ? it.artist : "-", level_label(it.actual_level), sz);
|
||||||
|
snprintf(title, sizeof title, "%s", it.name[0] ? it.name : "(未知歌曲)");
|
||||||
|
out->icon_kind = MEOW_LIST_ICON_NONE; /* 图标槽由红 overlays 画迷你黑胶 */
|
||||||
|
out->title = title;
|
||||||
|
out->subtitle = sub;
|
||||||
|
out->right = MEOW_LIST_RIGHT_NONE; /* 右侧「删除」由 z=60 叠层自绘 */
|
||||||
|
out->on = 0; out->enabled = 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行背景(z=5,画在 listview 文本之下):圆角卡 + 当前播放行红色高亮。 */
|
||||||
|
static void c_row_bg(void *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
if (meow_ui_activity_active() != ACT_CACHE) return;
|
||||||
|
int cnt = appstate_cache_count();
|
||||||
|
if (cnt <= 0) return;
|
||||||
|
const meow_theme *th = meow_theme_get();
|
||||||
|
const ncm_pal_t *pa = ncm_palette(g_dark);
|
||||||
|
int item = APP_ROW_H;
|
||||||
|
int off = meow_listview_scroll(g_list);
|
||||||
|
int first = off / item, last = (off + r_list.h) / item + 1;
|
||||||
|
if (last > cnt) last = cnt;
|
||||||
|
int cur = (appstate_get_queue() == NCM_QUEUE_CACHE) ? player_manager_playing_song() : -1;
|
||||||
|
for (int i = first; i < last; i++) {
|
||||||
|
int ry = ncm_row_card_y(item, i, off, r_list.y);
|
||||||
|
int th2 = ncm_row_card_h(item);
|
||||||
|
if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; /* 只画完整落在视口内的行 */
|
||||||
|
if (i == cur) {
|
||||||
|
ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, pa->red_ctr);
|
||||||
|
meow_ui_fill_rect(r_list.x + 2, ry + 4, 4, th2 - 8, pa->red);
|
||||||
|
} else {
|
||||||
|
ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, th->surfaceContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主绘制(z=60):迷你黑胶缩略 + 行右「删除」+ 按压红条;文本由 listview z=10 画。 */
|
||||||
|
static void c_draw(void *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
if (meow_ui_activity_active() != ACT_CACHE) return;
|
||||||
|
const meow_theme *th = meow_theme_get();
|
||||||
|
const ncm_pal_t *pa = ncm_palette(g_dark);
|
||||||
|
int cnt = appstate_cache_count();
|
||||||
|
if (cnt <= 0) return;
|
||||||
|
int item = APP_ROW_H, isz = 24;
|
||||||
|
int off = meow_listview_scroll(g_list);
|
||||||
|
int first = off / item, last = (off + r_list.h) / item + 1;
|
||||||
|
if (last > cnt) last = cnt;
|
||||||
|
for (int i = first; i < last; i++) {
|
||||||
|
int ry = ncm_row_card_y(item, i, off, r_list.y);
|
||||||
|
int th2 = ncm_row_card_h(item);
|
||||||
|
if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue;
|
||||||
|
int icy = ry + (th2 - isz) / 2, bx = r_list.x + 16;
|
||||||
|
ncm_fill_round(bx, icy, isz, isz, 6, pa->red);
|
||||||
|
meow_ui_fill_rect(bx + isz / 2 - 2, icy + isz / 2 - 2, 4, 4, pa->on_red); /* 盘芯 */
|
||||||
|
/* 行右「删除」(触达区 = 右 48px,见 cache_on_tap) */
|
||||||
|
meow_ui_text_right(r_list.x + r_list.w - APP_PAD,
|
||||||
|
ry + (th2 - 18) / 2, "删除", 18, pa->red);
|
||||||
|
if (g_press_row == i) {
|
||||||
|
meow_ui_fill_rect(r_list.x, ry, 4, th2, pa->red);
|
||||||
|
meow_ui_fill_rect_alpha(r_list.x, ry, r_list.w, th2, pa->red, 26);
|
||||||
|
}
|
||||||
|
/* 擦除 z=10 的 SDK 行分隔线(现落进卡片间空隙):以页底色填空隙带;不越出列表视口底。 */
|
||||||
|
int gy = ry + th2, gh = NCM_ROW_GAP;
|
||||||
|
if (gy + gh > r_list.y + r_list.h) gh = r_list.y + r_list.h - gy;
|
||||||
|
if (gh > 0) meow_ui_fill_rect(r_list.x, gy, r_list.w, gh, th->bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_draw_empty(void *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
if (meow_ui_activity_active() != ACT_CACHE) return;
|
||||||
|
if (appstate_cache_count() != 0) return;
|
||||||
|
if (g_cloading) { ncm_draw_empty(r_list, "LOADING", "正在扫描缓存..."); return; }
|
||||||
|
if (g_cerr) { ncm_draw_empty(r_list, "ERROR", "缓存目录读取失败"); return; }
|
||||||
|
ncm_draw_empty(r_list, "暂无缓存", "播放过的歌曲会自动缓存");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_draw_footer(void *ctx)
|
||||||
|
{
|
||||||
|
(void)ctx;
|
||||||
|
if (meow_ui_activity_active() != ACT_CACHE) return;
|
||||||
|
int n = appstate_cache_count();
|
||||||
|
if (n <= 0) return;
|
||||||
|
uint64_t total = 0;
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
if (appstate_cache_item(i, &it) == NCM_OK) total += it.size;
|
||||||
|
char sz[24], foot[96];
|
||||||
|
fmt_size(total, sz, sizeof sz);
|
||||||
|
snprintf(foot, sizeof foot, "%d 首 · 合计 %s", n, sz);
|
||||||
|
ncm_draw_footer(r_cont, foot);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_apply(void)
|
||||||
|
{
|
||||||
|
if (!g_list) return;
|
||||||
|
int n = appstate_cache_count();
|
||||||
|
meow_listview_set_count(g_list, n);
|
||||||
|
if (n != g_last_count) {
|
||||||
|
g_last_count = n;
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
const meow_theme *th = meow_theme_get();
|
||||||
|
meow_ui_set_page_bg(th->bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *cache_worker(void *arg)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
int rc = appstate_cache_list_load(); /* 扫描 + 文件内 tag + 旁路补名:全部磁盘 IO */
|
||||||
|
pthread_mutex_lock(&g_cmu);
|
||||||
|
g_cerr = (rc < 0) ? rc : 0;
|
||||||
|
g_cloading = 0;
|
||||||
|
g_cgen++;
|
||||||
|
pthread_mutex_unlock(&g_cmu);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_reload(void)
|
||||||
|
{
|
||||||
|
if (g_host_mock) { /* 宿主渲染:同步扫描(无线程/无延时,截图确定) */
|
||||||
|
int rc = appstate_cache_list_load();
|
||||||
|
g_cerr = (rc < 0) ? rc : 0;
|
||||||
|
g_cloading = 0;
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_cthr_on) return;
|
||||||
|
g_cloading = 1;
|
||||||
|
g_cthr_on = 1;
|
||||||
|
if (ncm_thread_create(&g_cthr, cache_worker, NULL) != 0) {
|
||||||
|
g_cthr_on = 0; g_cloading = 0;
|
||||||
|
g_cerr = NCM_ERR_IO;
|
||||||
|
}
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单曲删除(行右「删除」):删文件 + 摘除快照项 + 提示。 */
|
||||||
|
static void cache_delete_row(int row)
|
||||||
|
{
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
if (appstate_cache_item(row, &it) != NCM_OK) return;
|
||||||
|
char name[96], msg[160];
|
||||||
|
ncm_strlcpy(name, it.name[0] ? it.name : it.id, sizeof name);
|
||||||
|
if (appstate_cache_remove(row) == 0) {
|
||||||
|
snprintf(msg, sizeof msg, "已删除:%s", name);
|
||||||
|
ncm_snack(msg);
|
||||||
|
c_apply();
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
} else {
|
||||||
|
ncm_snack("删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 清空(顶栏「清空」):确认框(设置页「清理缓存」同语义:删除全部音频 + 封面缓存条目)。 */
|
||||||
|
static void cache_confirm_clear(void)
|
||||||
|
{
|
||||||
|
int n = appstate_cache_count();
|
||||||
|
if (n <= 0) { ncm_snack("缓存已为空"); return; }
|
||||||
|
uint64_t total = 0;
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
if (appstate_cache_item(i, &it) == NCM_OK) total += it.size;
|
||||||
|
char sz[24], msg[192];
|
||||||
|
fmt_size(total, sz, sizeof sz);
|
||||||
|
snprintf(msg, sizeof msg, "将删除全部 %d 首已缓存歌曲 (%s) 及封面缓存。\n已播放的歌曲需重新下载。",
|
||||||
|
n, sz);
|
||||||
|
meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "清空缓存?");
|
||||||
|
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) return;
|
||||||
|
appstate_cache_clear();
|
||||||
|
ncm_snack("缓存已清理");
|
||||||
|
c_apply();
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_open(void)
|
||||||
|
{
|
||||||
|
meow_view_page_push(ACT_CACHE);
|
||||||
|
cache_reload();
|
||||||
|
c_apply();
|
||||||
|
meow_ui_paint_full();
|
||||||
|
}
|
||||||
|
|
||||||
|
int cache_is_active(void) { return g_cc.active; }
|
||||||
|
|
||||||
|
int cache_on_tap(int x, int y)
|
||||||
|
{
|
||||||
|
if (g_tb) {
|
||||||
|
int ah = meow_topappbar_hit(g_tb, x, y);
|
||||||
|
if (ah == MEOW_TOPBAR_BACK) { c_drag_reset(); meow_view_page_pop(); return 1; }
|
||||||
|
if (ah == 0) { c_drag_reset(); cache_confirm_clear(); return 1; } /* 文本动作「清空」 */
|
||||||
|
}
|
||||||
|
if (meow_widget_hit(g_list, x, y)) {
|
||||||
|
int was_drag = (g_press_delta > 10);
|
||||||
|
int row = (g_press_row >= 0) ? g_press_row : meow_listview_hit(g_list, x, y);
|
||||||
|
int del = (x >= r_list.x + r_list.w - APP_OP_W);
|
||||||
|
c_drag_reset();
|
||||||
|
if (row < 0 || was_drag) return 1;
|
||||||
|
if (del) { cache_delete_row(row); return 1; }
|
||||||
|
player_play_cache_song(row);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_touch_down(int x, int y)
|
||||||
|
{
|
||||||
|
c_drag_reset();
|
||||||
|
if (appstate_cache_count() <= 0) return;
|
||||||
|
if (meow_widget_hit(g_list, x, y)) {
|
||||||
|
int row = meow_listview_hit(g_list, x, y);
|
||||||
|
if (row >= 0) g_press_row = row;
|
||||||
|
g_dragging = 1; g_drag_last_y = y;
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_touch_move(int x, int y)
|
||||||
|
{
|
||||||
|
(void)x;
|
||||||
|
if (!g_dragging) return;
|
||||||
|
if (g_press_row >= 0) {
|
||||||
|
int d = y - g_drag_last_y; if (d < 0) d = -d;
|
||||||
|
g_press_delta += d;
|
||||||
|
if (g_press_delta > 10) g_press_row = -1;
|
||||||
|
}
|
||||||
|
int dy = y - g_drag_last_y; g_drag_last_y = y;
|
||||||
|
if (dy == 0) return;
|
||||||
|
int before = meow_listview_scroll(g_list);
|
||||||
|
meow_listview_scroll_by(g_list, -dy);
|
||||||
|
if (meow_listview_scroll(g_list) != before)
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_swipe(int dir, int dist)
|
||||||
|
{
|
||||||
|
if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return;
|
||||||
|
if (appstate_cache_count() <= 0) return;
|
||||||
|
int del = (dir == MEOW_SWIPE_UP) ? dist : -dist;
|
||||||
|
int before = meow_listview_scroll(g_list);
|
||||||
|
meow_listview_scroll_by(g_list, del);
|
||||||
|
if (meow_listview_scroll(g_list) != before)
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache_tick(uint64_t now)
|
||||||
|
{
|
||||||
|
(void)now;
|
||||||
|
chrome_tick();
|
||||||
|
if (!g_cc.active) return;
|
||||||
|
if (!g_cthr_on) return;
|
||||||
|
int gen, acked;
|
||||||
|
pthread_mutex_lock(&g_cmu);
|
||||||
|
gen = g_cgen; acked = g_cacked;
|
||||||
|
pthread_mutex_unlock(&g_cmu);
|
||||||
|
if (gen == acked) return;
|
||||||
|
pthread_mutex_lock(&g_cmu);
|
||||||
|
g_cacked = gen;
|
||||||
|
pthread_mutex_unlock(&g_cmu);
|
||||||
|
g_cthr_on = 0;
|
||||||
|
if (g_cerr) ncm_snack("缓存读取失败");
|
||||||
|
c_apply();
|
||||||
|
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 生命周期 */
|
||||||
|
static void c_page_create(meow_view *v, void *ctx)
|
||||||
|
{
|
||||||
|
(void)v;
|
||||||
|
((cache_ctrl *)ctx)->view = v;
|
||||||
|
r_cont = ncm_content_rect();
|
||||||
|
r_list = NR(16, r_cont.y + 8, r_cont.w - 32,
|
||||||
|
r_cont.y + r_cont.h - APP_FOOTER_H - (r_cont.y + 8));
|
||||||
|
if (r_list.h < 0) r_list.h = 0;
|
||||||
|
meow_ui_activity_push(ACT_CACHE);
|
||||||
|
g_tb = ncm_topbar_create("已缓存", 1, "清空"); /* 文本动作(动作0)=清空缓存 */
|
||||||
|
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
|
||||||
|
c_src2, NULL, MEOW_LIST_SEL_NONE);
|
||||||
|
meow_listview_set_item_h(g_list, APP_ROW_H);
|
||||||
|
meow_ui_activity_pop();
|
||||||
|
meow_widget_set_z(g_list, 10);
|
||||||
|
g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h,
|
||||||
|
c_row_bg, NULL);
|
||||||
|
g_draw_id = meow_draw_custom_register(60, r_cont.x, r_cont.y, r_cont.w, r_cont.h,
|
||||||
|
c_draw, NULL);
|
||||||
|
g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h,
|
||||||
|
c_draw_empty, NULL);
|
||||||
|
g_footer_id = meow_draw_custom_register(1, r_cont.x, r_cont.y + r_cont.h - APP_FOOTER_H,
|
||||||
|
r_cont.w, APP_FOOTER_H, c_draw_footer, NULL);
|
||||||
|
c_drag_reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_page_enter(meow_view *v, void *ctx)
|
||||||
|
{
|
||||||
|
(void)v;
|
||||||
|
((cache_ctrl *)ctx)->active = 1;
|
||||||
|
const meow_theme *th = meow_theme_get();
|
||||||
|
meow_ui_set_page_bg(th->bg);
|
||||||
|
g_last_count = -1;
|
||||||
|
cache_reload(); /* 每次进入重扫(播放/删除后 LRU 变化) */
|
||||||
|
c_apply();
|
||||||
|
meow_ui_paint_full();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_page_leave(meow_view *v, void *ctx)
|
||||||
|
{
|
||||||
|
(void)v;
|
||||||
|
((cache_ctrl *)ctx)->active = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c_page_destroy(meow_view *v, void *ctx)
|
||||||
|
{
|
||||||
|
(void)v; (void)ctx;
|
||||||
|
if (g_bg_id) { meow_draw_unregister(g_bg_id); g_bg_id = 0; }
|
||||||
|
if (g_draw_id) { meow_draw_unregister(g_draw_id); g_draw_id = 0; }
|
||||||
|
if (g_empty_id) { meow_draw_unregister(g_empty_id); g_empty_id = 0; }
|
||||||
|
if (g_footer_id) { meow_draw_unregister(g_footer_id); g_footer_id = 0; }
|
||||||
|
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
|
||||||
|
if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }
|
||||||
|
}
|
||||||
|
|
||||||
|
meow_view *cache_view_create(void)
|
||||||
|
{
|
||||||
|
static const meow_view_hooks h = {
|
||||||
|
.on_create = c_page_create,
|
||||||
|
.on_enter = c_page_enter,
|
||||||
|
.on_leave = c_page_leave,
|
||||||
|
.on_destroy = c_page_destroy,
|
||||||
|
};
|
||||||
|
return meow_view_create(ACT_CACHE, MEOW_VIEW_ACTIVITY, &h, &g_cc);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/* ui/home.c — 我的音乐页(ACT_HOME,根页,已登录)。
|
/* ui/home.c — 我的音乐页(ACT_HOME,根页,已登录)。
|
||||||
*
|
*
|
||||||
* 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+搜索/刷新/设置/回播放页动作)+
|
* 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+刷新/设置/搜索/已缓存/回播放页动作)+
|
||||||
* 个人区卡片(头像/昵称/ID)+ 双页签「我创建的/我收藏的」(meow_tabbar + 各自 meow_listview)+
|
* 个人区卡片(头像/昵称/ID)+ 双页签「我创建的/我收藏的」(meow_tabbar + 各自 meow_listview)+
|
||||||
* 组头红条 + 歌单行(rounded card + 红 mini-vinyl 缩略 + 红色按压竖条)。
|
* 组头红条 + 歌单行(rounded card + 红 mini-vinyl 缩略 + 红色按压竖条)。
|
||||||
* 批6:顶栏「回播放页」按钮(sb_icons 位图,动作3 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。
|
* 批6:顶栏「回播放页」按钮(sb_icons 位图,动作4 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。
|
||||||
|
* M5:顶栏「已缓存」按钮(动作3,MEOW_ICON_DOWNLOAD 位图)→ 已缓存列表页 ACT_CACHE。
|
||||||
*
|
*
|
||||||
* 品牌红走 ncm_palette(g_dark)(App 无权改 SDK MD3 蓝 primary),代码零硬编码散落:
|
* 品牌红走 ncm_palette(g_dark)(App 无权改 SDK MD3 蓝 primary),代码零硬编码散落:
|
||||||
* 中性色读 meow_theme_get(),红读 ncm_palette()。SDK listview 的 MD3 蓝选中不可改,
|
* 中性色读 meow_theme_get(),红读 ncm_palette()。SDK listview 的 MD3 蓝选中不可改,
|
||||||
@@ -299,7 +300,8 @@ int home_on_tap(int x, int y)
|
|||||||
}
|
}
|
||||||
if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */
|
if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */
|
||||||
if (ah == 2) { home_drag_reset(); search_open(); return 1; } /* 搜索 */
|
if (ah == 2) { home_drag_reset(); search_open(); return 1; } /* 搜索 */
|
||||||
if (ah == 3) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */
|
if (ah == 3) { home_drag_reset(); cache_open(); return 1; } /* 已缓存列表 */
|
||||||
|
if (ah == 4) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */
|
||||||
}
|
}
|
||||||
if (g_tabs && meow_widget_hit(g_tabs, x, y)) {
|
if (g_tabs && meow_widget_hit(g_tabs, x, y)) {
|
||||||
int t = meow_tabbar_hit(g_tabs, x, y);
|
int t = meow_tabbar_hit(g_tabs, x, y);
|
||||||
@@ -376,15 +378,18 @@ static void home_page_create(meow_view *v, void *ctx)
|
|||||||
g_tb = ncm_topbar_create("我的音乐", 0, "\xe2\x86\xbb"); /* 刷新(动作0) */
|
g_tb = ncm_topbar_create("我的音乐", 0, "\xe2\x86\xbb"); /* 刷新(动作0) */
|
||||||
meow_topappbar_add_action(g_tb, "\xe2\x9a\x99"); /* 设置(动作1) */
|
meow_topappbar_add_action(g_tb, "\xe2\x9a\x99"); /* 设置(动作1) */
|
||||||
meow_topappbar_add_action(g_tb, "\xe2\x8c\x95"); /* 搜索(动作2) */
|
meow_topappbar_add_action(g_tb, "\xe2\x8c\x95"); /* 搜索(动作2) */
|
||||||
/* 批6:回播放页(动作3, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate
|
/* M5:已缓存列表入口(动作3)——songs/ 池全部已缓存歌曲(点播/删除/清空)。 */
|
||||||
|
meow_topappbar_add_action(g_tb, "\xe2\x87\xa9"); /* 已缓存(动作3) */
|
||||||
|
/* 批6:回播放页(动作4, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate
|
||||||
* 现有语义展示空态(暂无播放)/上次会话状态。 */
|
* 现有语义展示空态(暂无播放)/上次会话状态。 */
|
||||||
meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作3, 最右) */
|
meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作4, 最右) */
|
||||||
/* §307 topbar 图标迁移:三位码点(↻ ⚙ ⌕)在 4k 字库缺字形渲染成豆腐(WORK_STATE 310 前置
|
/* §307 topbar 图标迁移:三位码点(↻ ⚙ ⌕)在 4k 字库缺字形渲染成豆腐(WORK_STATE 310 前置
|
||||||
* 症状①),改为 sb_icons 位图(Material Symbols Rounded,字符标签仅作占位,绘制走位图)。 */
|
* 症状①),改为 sb_icons 位图(Material Symbols Rounded,字符标签仅作占位,绘制走位图)。 */
|
||||||
meow_topappbar_set_item_icon(g_tb, 0, MEOW_ICON_REFRESH);
|
meow_topappbar_set_item_icon(g_tb, 0, MEOW_ICON_REFRESH);
|
||||||
meow_topappbar_set_item_icon(g_tb, 1, MEOW_ICON_SETTINGS);
|
meow_topappbar_set_item_icon(g_tb, 1, MEOW_ICON_SETTINGS);
|
||||||
meow_topappbar_set_item_icon(g_tb, 2, MEOW_ICON_SEARCH);
|
meow_topappbar_set_item_icon(g_tb, 2, MEOW_ICON_SEARCH);
|
||||||
meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */
|
meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_DOWNLOAD); /* 已缓存列表 */
|
||||||
|
meow_topappbar_set_item_icon(g_tb, 4, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */
|
||||||
g_tabs = meow_tabbar_create(r_tabs.x, r_tabs.y, r_tabs.w, r_tabs.h);
|
g_tabs = meow_tabbar_create(r_tabs.x, r_tabs.y, r_tabs.w, r_tabs.h);
|
||||||
meow_tabbar_add_tab(g_tabs, tab_label(0));
|
meow_tabbar_add_tab(g_tabs, tab_label(0));
|
||||||
meow_tabbar_add_tab(g_tabs, tab_label(1));
|
meow_tabbar_add_tab(g_tabs, tab_label(1));
|
||||||
|
|||||||
@@ -783,6 +783,8 @@ void player_play_queue_song(int idx)
|
|||||||
int have = 0;
|
int have = 0;
|
||||||
if (appstate_get_queue() == NCM_QUEUE_SEARCH) {
|
if (appstate_get_queue() == NCM_QUEUE_SEARCH) {
|
||||||
if (appstate_search_song(idx, &s) == 0) have = 1;
|
if (appstate_search_song(idx, &s) == 0) have = 1;
|
||||||
|
} else if (appstate_get_queue() == NCM_QUEUE_CACHE) {
|
||||||
|
if (appstate_cache_song(idx, &s) == 0) have = 1;
|
||||||
} else if (appstate_song_copy(idx, &s) == 0) {
|
} else if (appstate_song_copy(idx, &s) == 0) {
|
||||||
have = 1;
|
have = 1;
|
||||||
}
|
}
|
||||||
@@ -794,6 +796,15 @@ void player_play_queue_song(int idx)
|
|||||||
meow_view_page_push(ACT_PLAYER);
|
meow_view_page_push(ACT_PLAYER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 已缓存列表点播(ACT_CACHE 页点行):切 NCM_QUEUE_CACHE 队列后走同一套「队列点播」路径——
|
||||||
|
* 上/下一首在缓存列表内循环;播放线程按条目真实键直读缓存文件(不重下)。 */
|
||||||
|
void player_play_cache_song(int i)
|
||||||
|
{
|
||||||
|
if (i < 0 || i >= appstate_cache_count()) return;
|
||||||
|
appstate_set_queue(NCM_QUEUE_CACHE);
|
||||||
|
player_play_queue_song(i);
|
||||||
|
}
|
||||||
|
|
||||||
void player_pause_toggle(int on)
|
void player_pause_toggle(int on)
|
||||||
{
|
{
|
||||||
if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0);
|
if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* ui/queue.c — 当前实时播放队列页(ACT_QUEUE,§NEW:播放页「队列」钮进入)。
|
/* ui/queue.c — 当前实时播放队列页(ACT_QUEUE,§NEW:播放页「队列」钮进入)。
|
||||||
*
|
*
|
||||||
* 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 或 搜索单曲结果集),
|
* 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 / 搜索单曲结果集 /
|
||||||
* 含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17):
|
* 已缓存列表),含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17):
|
||||||
* - count = appstate_queue_count()(全量:track_ids_count 或 search_song_count,可 5194 首);
|
* - count = appstate_queue_count()(全量:track_ids_count 或 search_song_count,可 5194 首);
|
||||||
* - 行内容 = src2 逐行按 queue_src 取(PLAYLIST→滑窗拷贝,SEARCH→搜索数组),未物化槽显示
|
* - 行内容 = src2 逐行按 queue_src 取(PLAYLIST→滑窗拷贝,SEARCH→搜索数组),未物化槽显示
|
||||||
* ".../加载中" 占位,视口 ±24 行未物化 → 后台 worker ensure_range 补载(盘缓存优先,批≤NCM_SONG_BATCH);
|
* ".../加载中" 占位,视口 ±24 行未物化 → 后台 worker ensure_range 补载(盘缓存优先,批≤NCM_SONG_BATCH);
|
||||||
@@ -47,8 +47,11 @@ static void q_drag_reset(void)
|
|||||||
* PLAYLIST:滑窗拷贝(UI 线程不触发网络);SEARCH:搜索数组(内存)。 */
|
* PLAYLIST:滑窗拷贝(UI 线程不触发网络);SEARCH:搜索数组(内存)。 */
|
||||||
static int q_song_get(int idx, ncm_song_t *out)
|
static int q_song_get(int idx, ncm_song_t *out)
|
||||||
{
|
{
|
||||||
if (appstate_get_queue() == NCM_QUEUE_SEARCH)
|
int src = appstate_get_queue();
|
||||||
|
if (src == NCM_QUEUE_SEARCH)
|
||||||
return (appstate_search_song(idx, out) == 0) ? 0 : -1;
|
return (appstate_search_song(idx, out) == 0) ? 0 : -1;
|
||||||
|
if (src == NCM_QUEUE_CACHE)
|
||||||
|
return (appstate_cache_song(idx, out) == 0) ? 0 : -1;
|
||||||
return (appstate_song_copy(idx, out) == 0) ? 0 : -1;
|
return (appstate_song_copy(idx, out) == 0) ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +198,7 @@ static void *queue_worker(void *arg)
|
|||||||
* SEARCH 队列全在内存,无需补载。 */
|
* SEARCH 队列全在内存,无需补载。 */
|
||||||
static void q_maybe_ensure(void)
|
static void q_maybe_ensure(void)
|
||||||
{
|
{
|
||||||
if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return;
|
if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; /* SEARCH/CACHE 队列全在内存 */
|
||||||
if (g_qthr_on || g_host_mock) return;
|
if (g_qthr_on || g_host_mock) return;
|
||||||
int total = appstate_queue_count();
|
int total = appstate_queue_count();
|
||||||
if (total <= 0) return;
|
if (total <= 0) return;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ INC_CORE="-Icore"
|
|||||||
INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c"
|
INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c"
|
||||||
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
|
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
|
||||||
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
|
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
|
||||||
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c"
|
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c"
|
||||||
VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译
|
VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译
|
||||||
JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU
|
JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ STRICT="-Wall -Wextra -Os -DJSMN_HEADER"
|
|||||||
|
|
||||||
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
|
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
|
||||||
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
|
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
|
||||||
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c"
|
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c"
|
||||||
APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
|
APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
|
||||||
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \
|
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \
|
||||||
|
app/ui/ncm_colors.c app/ui/chrome.c \
|
||||||
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
|
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
|
||||||
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
|
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
|
||||||
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c \
|
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c \
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ $CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_ho
|
|||||||
|
|
||||||
echo "==> host app sources (exclude app.c: has main + g_st; test provides them)"
|
echo "==> host app sources (exclude app.c: has main + g_st; test provides them)"
|
||||||
APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
|
APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
|
||||||
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \
|
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \
|
||||||
|
app/ui/ncm_colors.c app/ui/chrome.c \
|
||||||
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
|
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
|
||||||
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
|
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
|
||||||
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c"
|
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c"
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# build_ncm_tag_host.sh — 宿主单测:core/ncm_tag.c(纯 libc;不依赖 mbedtls/jsmn/vendor,故独立脚本)。
|
||||||
|
# 覆盖 FLAC VORBIS_COMMENT / MP3 ID3v2.3 的构建、读回、分块大文件与失败降级。
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
CC_HOST=${CC_HOST:-gcc}
|
||||||
|
OUT="test/test_ncm_tag_host"
|
||||||
|
$CC_HOST -O2 -Wall -Wextra -Icore core/ncm_tag.c test/test_ncm_tag.c -o "$OUT" \
|
||||||
|
|| { echo "build test_ncm_tag FAILED"; exit 1; }
|
||||||
|
echo "build OK: $OUT"
|
||||||
|
echo "run: ./$OUT"
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "ncm_login.h"
|
#include "ncm_login.h"
|
||||||
#include "ncm_api.h"
|
#include "ncm_api.h"
|
||||||
#include "ncm_cache.h"
|
#include "ncm_cache.h"
|
||||||
|
#include "ncm_tag.h"
|
||||||
#include "ncm_apicache.h"
|
#include "ncm_apicache.h"
|
||||||
|
|
||||||
#define NCM_CTX_CA_PATH_MAX 256
|
#define NCM_CTX_CA_PATH_MAX 256
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ const char *ncm_api_level_key(int level) {
|
|||||||
return ncm_api_level_str(level);
|
return ncm_api_level_str(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 键后缀 → 档位枚举(缓存扫描/「已缓存列表」用;与 ncm_api_level_key 严格互逆)。 */
|
||||||
|
int ncm_api_level_from_key(const char *key) {
|
||||||
|
if (!key || !key[0]) return -1;
|
||||||
|
if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND;
|
||||||
|
if (!strcmp(key, "sky")) return NCM_LEVEL_SKY;
|
||||||
|
if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER;
|
||||||
|
if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT;
|
||||||
|
if (!strcmp(key, "dolby")) return NCM_LEVEL_DOLBY;
|
||||||
|
if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES;
|
||||||
|
if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS;
|
||||||
|
if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH;
|
||||||
|
if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER;
|
||||||
|
if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
int ncm_api_level_gate(int level) {
|
int ncm_api_level_gate(int level) {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case NCM_LEVEL_LOSSLESS:
|
case NCM_LEVEL_LOSSLESS:
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ enum {
|
|||||||
};
|
};
|
||||||
const char *ncm_api_level_str(int level); /* API level 串(SKY/SURROUND 同为 "sky") */
|
const char *ncm_api_level_str(int level); /* API level 串(SKY/SURROUND 同为 "sky") */
|
||||||
const char *ncm_api_level_key(int level); /* 缓存键唯一后缀(SKY="sky"/SURROUND="sky_c51") */
|
const char *ncm_api_level_key(int level); /* 缓存键唯一后缀(SKY="sky"/SURROUND="sky_c51") */
|
||||||
|
/* 缓存键后缀 → 档位枚举(ncm_api_level_key 的逆;未知/空返回 -1)。 */
|
||||||
|
int ncm_api_level_from_key(const char *key);
|
||||||
|
|
||||||
/* 会员等级(vip/info 判定,BIZ_NOTES §1):用户语义 0=非会员 / 1=畅听会员(VIP) /
|
/* 会员等级(vip/info 判定,BIZ_NOTES §1):用户语义 0=非会员 / 1=畅听会员(VIP) /
|
||||||
* 2=黑胶会员(CVIP) / 3=超级会员(SVIP)。枚举名保留旧名(CVIP/SVIP)避免大改,新增
|
* 2=黑胶会员(CVIP) / 3=超级会员(SVIP)。枚举名保留旧名(CVIP/SVIP)避免大改,新增
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
#include "ncm_cache.h"
|
#include "ncm_cache.h"
|
||||||
#include "ncm_common.h"
|
#include "ncm_common.h"
|
||||||
#include "ncm_store.h"
|
#include "ncm_store.h"
|
||||||
|
#include "ncm_tag.h"
|
||||||
|
#include "ncm_api.h"
|
||||||
|
|
||||||
#include <dirent.h>
|
#include <dirent.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
@@ -461,3 +463,134 @@ int ncm_cache_clear(const ncm_cache_t *c) {
|
|||||||
}
|
}
|
||||||
return NCM_OK;
|
return NCM_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 「已缓存列表」:扫描/删除(键=歌曲ID+档位,NCM_CACHE_DESIGN §2.5)----
|
||||||
|
* 文件名 song_<id>_<请求档位>.<实际格式>;**档位段只是请求档**,实际档由文件头探测得出
|
||||||
|
* (真机 song_<id>_hires.mp3 = 无损请求被降级链落到 exhigh mp3)。 */
|
||||||
|
|
||||||
|
/* 解析条目名 → id/请求档位键/实际扩展名。song_ 后必须是数字 id(排除 song_cov* 封面)。
|
||||||
|
* 返回 0=是歌曲条目。 */
|
||||||
|
static int parse_item_name(const char *nm, ncm_cache_item_t *it)
|
||||||
|
{
|
||||||
|
const char *p;
|
||||||
|
size_t n = 0;
|
||||||
|
if (!nm || strncmp(nm, "song_", 5) != 0) return -1;
|
||||||
|
p = nm + 5;
|
||||||
|
while (p[n] >= '0' && p[n] <= '9' && n + 1 < sizeof it->id) { it->id[n] = p[n]; n++; }
|
||||||
|
if (n == 0 || (p[n] >= '0' && p[n] <= '9')) return -1;
|
||||||
|
it->id[n] = 0;
|
||||||
|
p += n;
|
||||||
|
it->level[0] = 0;
|
||||||
|
if (*p == '_') {
|
||||||
|
size_t m = 0;
|
||||||
|
p++;
|
||||||
|
while (*p && *p != '.' && m + 1 < sizeof it->level) it->level[m++] = *p++;
|
||||||
|
it->level[m] = 0;
|
||||||
|
}
|
||||||
|
while (*p == '.') p++; /* 历史双点名 song_<id>..mp3 归一 */
|
||||||
|
if (!*p) return -1;
|
||||||
|
ncm_strlcpy(it->ext, p, sizeof it->ext);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 请求档 + 实际容器参数 → 实际档(只降不升:显示「已降级至 X」语义)。 */
|
||||||
|
static int actual_level_of(int req, const ncm_audio_info_t *ai)
|
||||||
|
{
|
||||||
|
int cap;
|
||||||
|
if (!ai || !ai->container[0]) return req; /* 探测失败:保留请求档 */
|
||||||
|
if (strcmp(ai->container, "flac") == 0) {
|
||||||
|
if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS;
|
||||||
|
else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER; /* 192k/24 */
|
||||||
|
else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT; /* 96k/24 */
|
||||||
|
else cap = NCM_LEVEL_HIRES; /* 48k/24 */
|
||||||
|
} else if (strcmp(ai->container, "mp3") == 0) {
|
||||||
|
cap = (ai->bitrate_kbps > 128) ? NCM_LEVEL_EXHIGH : NCM_LEVEL_STANDARD;
|
||||||
|
} else {
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
if (req < 0) return cap;
|
||||||
|
return (req < cap) ? req : cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cmp_item_mtime_desc(const void *a, const void *b)
|
||||||
|
{
|
||||||
|
const ncm_cache_item_t *x = (const ncm_cache_item_t *)a;
|
||||||
|
const ncm_cache_item_t *y = (const ncm_cache_item_t *)b;
|
||||||
|
if (x->mtime > y->mtime) return -1;
|
||||||
|
if (x->mtime < y->mtime) return 1;
|
||||||
|
return strcmp(x->id, y->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max)
|
||||||
|
{
|
||||||
|
const char *store;
|
||||||
|
DIR *d;
|
||||||
|
struct dirent *e;
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
if (!c || !out || max <= 0) return NCM_ERR_ARG;
|
||||||
|
store = (c->songs_dir[0]) ? c->songs_dir : c->dir;
|
||||||
|
d = opendir(store);
|
||||||
|
if (!d) return NCM_ERR_IO;
|
||||||
|
while ((e = readdir(d))) {
|
||||||
|
ncm_cache_item_t it;
|
||||||
|
char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8];
|
||||||
|
struct stat st;
|
||||||
|
ncm_audio_info_t ai;
|
||||||
|
ncm_tag_meta_t tag;
|
||||||
|
int slot;
|
||||||
|
|
||||||
|
if (strncmp(e->d_name, "song_", 5) != 0) continue;
|
||||||
|
if (is_partial_name(e->d_name)) continue; /* .tmp/.part:写者管理域 */
|
||||||
|
memset(&it, 0, sizeof it);
|
||||||
|
if (parse_item_name(e->d_name, &it) != 0) continue;
|
||||||
|
snprintf(p, sizeof p, "%s/%s", store, e->d_name);
|
||||||
|
if (stat(p, &st) != 0 || !S_ISREG(st.st_mode)) continue;
|
||||||
|
it.size = (uint64_t)st.st_size;
|
||||||
|
it.mtime = (int64_t)st.st_mtim.tv_sec;
|
||||||
|
if (n < max) {
|
||||||
|
slot = n;
|
||||||
|
} else {
|
||||||
|
/* 池内条目多于展示上限:只保留最近访问的 max 条 */
|
||||||
|
int oldest = 0;
|
||||||
|
for (int i = 1; i < n; i++)
|
||||||
|
if (out[i].mtime < out[oldest].mtime) oldest = i;
|
||||||
|
if (it.mtime <= out[oldest].mtime) continue;
|
||||||
|
slot = oldest;
|
||||||
|
}
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
it.actual_level = actual_level_of(ncm_api_level_from_key(it.level),
|
||||||
|
(ncm_audio_probe_file(p, &ai) == NCM_OK) ? &ai : NULL);
|
||||||
|
if (ncm_tag_read_file(p, &tag) == NCM_OK) { /* 文件内 tag 优先(外部播放器同源) */
|
||||||
|
ncm_strlcpy(it.name, tag.title, sizeof it.name);
|
||||||
|
ncm_strlcpy(it.artist, tag.artist, sizeof it.artist);
|
||||||
|
}
|
||||||
|
out[slot] = it;
|
||||||
|
if (n < max) n++;
|
||||||
|
}
|
||||||
|
closedir(d);
|
||||||
|
qsort(out, (size_t)n, sizeof *out, cmp_item_mtime_desc);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext)
|
||||||
|
{
|
||||||
|
char key[48];
|
||||||
|
char path[NCM_CACHE_DIR_MAX + 64];
|
||||||
|
|
||||||
|
if (!c || !id || !id[0]) return NCM_ERR_ARG;
|
||||||
|
if (level && level[0]) snprintf(key, sizeof key, "%s_%s", id, level);
|
||||||
|
else ncm_strlcpy(key, id, sizeof key);
|
||||||
|
if (ncm_cache_path(c, key, (ext && ext[0]) ? ext : "mp3", path, sizeof path) != NCM_OK)
|
||||||
|
return NCM_ERR_FULL;
|
||||||
|
if (unlink(path) == 0) return NCM_OK;
|
||||||
|
/* §19 历史双点名 song_<id>..<ext>(scan 已归一成单点 ext)兜底删除 */
|
||||||
|
{
|
||||||
|
char legacy[NCM_CACHE_DIR_MAX + 64];
|
||||||
|
const char *e = (ext && ext[0]) ? ext : "mp3";
|
||||||
|
const char *base = cache_base_dir(c, key);
|
||||||
|
int k = snprintf(legacy, sizeof legacy, "%s/song_%s..%s", base, key, e);
|
||||||
|
if (k > 0 && (size_t)k < sizeof legacy && unlink(legacy) == 0) return NCM_OK;
|
||||||
|
}
|
||||||
|
return NCM_ERR_NOENT;
|
||||||
|
}
|
||||||
@@ -87,6 +87,30 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c);
|
|||||||
/* 立即执行 LRU 淘汰(扫描 songs/;超上限则删最旧,直到 <= 上限;跳过 .tmp/.part 半成品)。 */
|
/* 立即执行 LRU 淘汰(扫描 songs/;超上限则删最旧,直到 <= 上限;跳过 .tmp/.part 半成品)。 */
|
||||||
int ncm_cache_evict(const ncm_cache_t *c);
|
int ncm_cache_evict(const ncm_cache_t *c);
|
||||||
|
|
||||||
|
/* 缓存条目(ncm_cache_scan 输出,供「已缓存列表」展示/点播/删除)。
|
||||||
|
* 命名约定 song_<id>_<请求档位>.<实际格式>;**档位段是请求档,不是实际档**——真机存在
|
||||||
|
* song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3)。故 scan 用 ncm_audio_probe_file
|
||||||
|
* 读实际容器参数,actual_level 给**实际档**(-1=无法判定);播放/删除仍用 id+level+ext 定位。 */
|
||||||
|
typedef struct ncm_cache_item {
|
||||||
|
char id[24]; /* 歌曲 id(数字串) */
|
||||||
|
char level[20]; /* 文件名里的请求档位键(""=旧无档位键条目) */
|
||||||
|
char ext[8]; /* 实际容器扩展名(mp3/flac,去点) */
|
||||||
|
int actual_level; /* 实际档 NCM_LEVEL_*(按容器/位深/码率推断;-1=未知) */
|
||||||
|
uint64_t size; /* 文件字节数(含嵌入 tag) */
|
||||||
|
int64_t mtime; /* 最后访问/写入(LRU 序) */
|
||||||
|
char name[96]; /* 文件内 tag 标题(无 tag 为空,调用方可回退旁路元数据) */
|
||||||
|
char artist[64]; /* 文件内 tag 歌手 */
|
||||||
|
} ncm_cache_item_t;
|
||||||
|
|
||||||
|
/* 扫描歌曲缓存池(songs/,跳过 .tmp/.part 半成品与非 song_<数字 id> 条目),按 mtime 降序
|
||||||
|
* (最近播放/写入在前)写入 out(最多 max 条;池内更多时保留最近的 max 条)。返回条目数或负错误码。
|
||||||
|
* 每条会读文件头(tag + 音频参数探测),属磁盘 IO——**必须在 worker 线程调用**。 */
|
||||||
|
int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max);
|
||||||
|
|
||||||
|
/* 删除单条缓存:id+level(level 可为 "")+ext 定位 song_<id>[_<level>].<ext>。
|
||||||
|
* 返回 0=已删除;NCM_ERR_NOENT=不存在。 */
|
||||||
|
int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext);
|
||||||
|
|
||||||
/* 清空缓存池:删除 songs/ 与 covers/ 的 song_* 全部文件(音频 + 封面条目 + 残留 .tmp/.part)。 */
|
/* 清空缓存池:删除 songs/ 与 covers/ 的 song_* 全部文件(音频 + 封面条目 + 残留 .tmp/.part)。 */
|
||||||
int ncm_cache_clear(const ncm_cache_t *c);
|
int ncm_cache_clear(const ncm_cache_t *c);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,733 @@
|
|||||||
|
/* ncm_tag.c — 缓存音频文件内元数据嵌入/读取实现(纯 libc;接口见 ncm_tag.h)
|
||||||
|
*
|
||||||
|
* 只做三件事:①构建 ID3v2.3 / VORBIS_COMMENT 字节;②把「tag 前缀 + 原音频」写成同目录
|
||||||
|
* 临时文件(64KB 分块拷贝)再 rename;③从文件头解析 tag。
|
||||||
|
*
|
||||||
|
* 为什么不做「下载时前缀直写」:边下边播的解码端 pread 的是下载中的 .tmp,若写端在头部
|
||||||
|
* 插入 tag,续传的 Range 偏移与解码 read_pos 都要按前缀长度换算(FLAC 前缀长度还取决于
|
||||||
|
* 源文件既有元数据块),风险远大于收益。改为「下载完整、曲终 commit 前一次性重写」:
|
||||||
|
* - 解码全程读原 inode(tag 重写 rename 不打断已打开的 fd),播放链路零改动;
|
||||||
|
* - .part 续传字节语义保持原样(残体永远是裸流);
|
||||||
|
* - 失败即放弃重写 → commit 裸流(降级路径)。
|
||||||
|
* 代价是一次整文件读+写(TF 卡顺序 IO),峰值内存仅 64KB。
|
||||||
|
*/
|
||||||
|
#include "ncm_tag.h"
|
||||||
|
#include "ncm_common.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define NCM_TAG_ID3_MAX (NCM_TAG_CHUNK) /* 读取时最多解析的 ID3 区字节 */
|
||||||
|
#define NCM_TAG_PATH_MAX 1024
|
||||||
|
#define NCM_TAG_VENDOR "MeowOS ncm"
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* 基础 IO / 字节序 */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
static int write_full(int fd, const void *buf, size_t n)
|
||||||
|
{
|
||||||
|
const unsigned char *p = (const unsigned char *)buf;
|
||||||
|
while (n > 0) {
|
||||||
|
ssize_t w = write(fd, p, n);
|
||||||
|
if (w < 0) { if (errno == EINTR) continue; return -1; }
|
||||||
|
if (w == 0) return -1;
|
||||||
|
p += (size_t)w; n -= (size_t)w;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尽力读满 n 字节;EOF 提前返回已读字节数;错误返回 -1。 */
|
||||||
|
static ssize_t read_full(int fd, void *buf, size_t n)
|
||||||
|
{
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
size_t got = 0;
|
||||||
|
while (got < n) {
|
||||||
|
ssize_t r = read(fd, p + got, n - got);
|
||||||
|
if (r < 0) { if (errno == EINTR) continue; return -1; }
|
||||||
|
if (r == 0) break;
|
||||||
|
got += (size_t)r;
|
||||||
|
}
|
||||||
|
return (ssize_t)got;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int pread_full(int fd, void *buf, size_t n, uint64_t off)
|
||||||
|
{
|
||||||
|
unsigned char *p = (unsigned char *)buf;
|
||||||
|
size_t got = 0;
|
||||||
|
while (got < n) {
|
||||||
|
ssize_t r = pread(fd, p + got, n - got, (off_t)(off + got));
|
||||||
|
if (r < 0) { if (errno == EINTR) continue; return -1; }
|
||||||
|
if (r == 0) break;
|
||||||
|
got += (size_t)r;
|
||||||
|
}
|
||||||
|
return (int)got;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_be24(unsigned char *p, uint32_t v)
|
||||||
|
{
|
||||||
|
p[0] = (unsigned char)((v >> 16) & 0xff);
|
||||||
|
p[1] = (unsigned char)((v >> 8) & 0xff);
|
||||||
|
p[2] = (unsigned char)(v & 0xff);
|
||||||
|
}
|
||||||
|
static uint32_t get_be24(const unsigned char *p)
|
||||||
|
{
|
||||||
|
return ((uint32_t)p[0] << 16) | ((uint32_t)p[1] << 8) | (uint32_t)p[2];
|
||||||
|
}
|
||||||
|
static void put_le32(unsigned char *p, uint32_t v)
|
||||||
|
{
|
||||||
|
p[0] = (unsigned char)(v & 0xff);
|
||||||
|
p[1] = (unsigned char)((v >> 8) & 0xff);
|
||||||
|
p[2] = (unsigned char)((v >> 16) & 0xff);
|
||||||
|
p[3] = (unsigned char)((v >> 24) & 0xff);
|
||||||
|
}
|
||||||
|
static uint32_t get_le32(const unsigned char *p)
|
||||||
|
{
|
||||||
|
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
|
||||||
|
((uint32_t)p[3] << 24);
|
||||||
|
}
|
||||||
|
static uint32_t synchsafe32(const unsigned char *p)
|
||||||
|
{
|
||||||
|
return ((uint32_t)(p[0] & 0x7f) << 21) | ((uint32_t)(p[1] & 0x7f) << 14) |
|
||||||
|
((uint32_t)(p[2] & 0x7f) << 7) | (uint32_t)(p[3] & 0x7f);
|
||||||
|
}
|
||||||
|
static void put_synchsafe32(unsigned char *p, uint32_t v)
|
||||||
|
{
|
||||||
|
p[0] = (unsigned char)((v >> 21) & 0x7f);
|
||||||
|
p[1] = (unsigned char)((v >> 14) & 0x7f);
|
||||||
|
p[2] = (unsigned char)((v >> 7) & 0x7f);
|
||||||
|
p[3] = (unsigned char)(v & 0x7f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定长字段的安全取串(字段可能未经 NUL 结尾,防御性按容量截断)。 */
|
||||||
|
static size_t tag_strlen(const char *s, size_t cap)
|
||||||
|
{
|
||||||
|
size_t n = 0;
|
||||||
|
if (!s) return 0;
|
||||||
|
while (n < cap && s[n]) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UTF-8 安全截断拷贝:不超过 cap-1 字节,切点回退到字符边界。 */
|
||||||
|
static void utf8_copy(char *dst, size_t cap, const char *src, size_t len)
|
||||||
|
{
|
||||||
|
size_t n;
|
||||||
|
if (!dst || cap == 0) return;
|
||||||
|
if (!src) { dst[0] = 0; return; }
|
||||||
|
n = (len < cap - 1) ? len : cap - 1;
|
||||||
|
while (n > 0 && n < len && ((unsigned char)src[n] & 0xC0) == 0x80) n--;
|
||||||
|
memcpy(dst, src, n);
|
||||||
|
dst[n] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* UTF-8 ↔ UTF-16LE(ID3v2.3 文本帧用 UTF-16 + BOM,中英文通用) */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* 解码一个 UTF-8 码点;返回消耗字节数(≥1),*cp 回填。非法序列 → U+FFFD。 */
|
||||||
|
static size_t utf8_next(const unsigned char *s, size_t len, uint32_t *cp)
|
||||||
|
{
|
||||||
|
unsigned char c = s[0];
|
||||||
|
if (c < 0x80) { *cp = c; return 1; }
|
||||||
|
if ((c & 0xE0) == 0xC0 && len >= 2 && (s[1] & 0xC0) == 0x80) {
|
||||||
|
*cp = ((uint32_t)(c & 0x1f) << 6) | (uint32_t)(s[1] & 0x3f);
|
||||||
|
return (*cp >= 0x80) ? 2 : 1;
|
||||||
|
}
|
||||||
|
if ((c & 0xF0) == 0xE0 && len >= 3 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80) {
|
||||||
|
*cp = ((uint32_t)(c & 0x0f) << 12) | ((uint32_t)(s[1] & 0x3f) << 6) |
|
||||||
|
(uint32_t)(s[2] & 0x3f);
|
||||||
|
return (*cp >= 0x800) ? 3 : 1;
|
||||||
|
}
|
||||||
|
if ((c & 0xF8) == 0xF0 && len >= 4 && (s[1] & 0xC0) == 0x80 &&
|
||||||
|
(s[2] & 0xC0) == 0x80 && (s[3] & 0xC0) == 0x80) {
|
||||||
|
*cp = ((uint32_t)(c & 0x07) << 18) | ((uint32_t)(s[1] & 0x3f) << 12) |
|
||||||
|
((uint32_t)(s[2] & 0x3f) << 6) | (uint32_t)(s[3] & 0x3f);
|
||||||
|
return (*cp >= 0x10000 && *cp <= 0x10ffff) ? 4 : 1;
|
||||||
|
}
|
||||||
|
*cp = 0xFFFD;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_u16le(unsigned char *p, uint16_t v)
|
||||||
|
{
|
||||||
|
p[0] = (unsigned char)(v & 0xff);
|
||||||
|
p[1] = (unsigned char)(v >> 8);
|
||||||
|
}
|
||||||
|
static uint16_t get_u16le(const unsigned char *p)
|
||||||
|
{
|
||||||
|
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* s 为 UTF-8(定长字段),写入 UTF-16LE 码元;返回写入字节数(不含 BOM)。 */
|
||||||
|
static size_t utf8_to_utf16le(const char *s, size_t cap, unsigned char *out, size_t out_cap)
|
||||||
|
{
|
||||||
|
size_t len = tag_strlen(s, cap), i = 0, o = 0;
|
||||||
|
while (i < len && o + 2 <= out_cap) {
|
||||||
|
uint32_t cp = 0;
|
||||||
|
size_t adv = utf8_next((const unsigned char *)s + i, len - i, &cp);
|
||||||
|
i += adv;
|
||||||
|
if (cp >= 0x10000) { /* 代理对 */
|
||||||
|
uint32_t v = cp - 0x10000;
|
||||||
|
if (o + 4 > out_cap) break;
|
||||||
|
put_u16le(out + o, (uint16_t)(0xD800 | (v >> 10))); o += 2;
|
||||||
|
put_u16le(out + o, (uint16_t)(0xDC00 | (v & 0x3FF))); o += 2;
|
||||||
|
} else {
|
||||||
|
put_u16le(out + o, (uint16_t)cp); o += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t put_utf8_cp(char *out, size_t cap, uint32_t cp)
|
||||||
|
{
|
||||||
|
if (cp < 0x80) {
|
||||||
|
if (cap < 1) return 0;
|
||||||
|
out[0] = (char)cp; return 1;
|
||||||
|
}
|
||||||
|
if (cp < 0x800) {
|
||||||
|
if (cap < 2) return 0;
|
||||||
|
out[0] = (char)(0xC0 | (cp >> 6));
|
||||||
|
out[1] = (char)(0x80 | (cp & 0x3f));
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (cp < 0x10000) {
|
||||||
|
if (cap < 3) return 0;
|
||||||
|
out[0] = (char)(0xE0 | (cp >> 12));
|
||||||
|
out[1] = (char)(0x80 | ((cp >> 6) & 0x3f));
|
||||||
|
out[2] = (char)(0x80 | (cp & 0x3f));
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (cp <= 0x10ffff) {
|
||||||
|
if (cap < 4) return 0;
|
||||||
|
out[0] = (char)(0xF0 | (cp >> 18));
|
||||||
|
out[1] = (char)(0x80 | ((cp >> 12) & 0x3f));
|
||||||
|
out[2] = (char)(0x80 | ((cp >> 6) & 0x3f));
|
||||||
|
out[3] = (char)(0x80 | (cp & 0x3f));
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UTF-16(LE/BE 由 BOM 定;无 BOM 按 LE)→ UTF-8,结果写入 dst(安全截断)。 */
|
||||||
|
static void utf16_to_utf8(const unsigned char *in, size_t bytes, char *dst, size_t cap)
|
||||||
|
{
|
||||||
|
size_t i = 0, o = 0;
|
||||||
|
int be = 0;
|
||||||
|
if (bytes >= 2 && in[0] == 0xFF && in[1] == 0xFE) { i = 2; }
|
||||||
|
else if (bytes >= 2 && in[0] == 0xFE && in[1] == 0xFF) { i = 2; be = 1; }
|
||||||
|
while (i + 2 <= bytes) {
|
||||||
|
uint32_t u = be ? (uint32_t)(((uint16_t)in[i] << 8) | in[i + 1])
|
||||||
|
: (uint32_t)get_u16le(in + i);
|
||||||
|
if (o + 4 >= cap) break;
|
||||||
|
i += 2;
|
||||||
|
if (u == 0) break; /* 提前终止符 */
|
||||||
|
if (u >= 0xD800 && u <= 0xDBFF && i + 2 <= bytes) {
|
||||||
|
uint32_t lo = be ? (uint32_t)(((uint16_t)in[i] << 8) | in[i + 1])
|
||||||
|
: (uint32_t)get_u16le(in + i);
|
||||||
|
if (lo >= 0xDC00 && lo <= 0xDFFF) {
|
||||||
|
u = 0x10000 + ((u - 0xD800) << 10) + (lo - 0xDC00);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size_t w = put_utf8_cp(dst + o, cap - o - 1, u);
|
||||||
|
if (w == 0) break;
|
||||||
|
o += w;
|
||||||
|
}
|
||||||
|
dst[o] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ISO-8859-1 → UTF-8(ID3v2.3 编码 0)。 */
|
||||||
|
static void latin1_to_utf8(const unsigned char *in, size_t bytes, char *dst, size_t cap)
|
||||||
|
{
|
||||||
|
size_t o = 0;
|
||||||
|
for (size_t i = 0; i < bytes && o + 4 < cap; i++) {
|
||||||
|
uint32_t cp = in[i];
|
||||||
|
if (cp == 0) break;
|
||||||
|
o += put_utf8_cp(dst + o, cap - o - 1, cp);
|
||||||
|
}
|
||||||
|
dst[o] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* ID3v2(MP3) */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* 单帧:id + 大小(4/3字节) + flags(2/0) + [编码字节 + 文本]。返回写入字节数。 */
|
||||||
|
static size_t id3_frame(unsigned char *out, size_t cap, const char *id4,
|
||||||
|
const char *text, size_t text_cap)
|
||||||
|
{
|
||||||
|
unsigned char body[NCM_TAG_TITLE_MAX * 2 + 8];
|
||||||
|
size_t blen = 3; /* 编码字节 + BOM */
|
||||||
|
size_t need, flen;
|
||||||
|
|
||||||
|
if (text_cap + 4 > sizeof body) return 0;
|
||||||
|
body[0] = 0x01; /* UTF-16 with BOM */
|
||||||
|
body[1] = 0xFF; body[2] = 0xFE; /* BOM = LE */
|
||||||
|
blen += utf8_to_utf16le(text, text_cap, body + blen, sizeof body - blen);
|
||||||
|
flen = blen;
|
||||||
|
need = 10 + flen; /* 帧头 10 字节(ID3v2.3) */
|
||||||
|
if (cap < need) return 0;
|
||||||
|
memcpy(out, id4, 4);
|
||||||
|
out[4] = (unsigned char)((flen >> 24) & 0xff);
|
||||||
|
out[5] = (unsigned char)((flen >> 16) & 0xff);
|
||||||
|
out[6] = (unsigned char)((flen >> 8) & 0xff);
|
||||||
|
out[7] = (unsigned char)(flen & 0xff);
|
||||||
|
out[8] = 0; out[9] = 0;
|
||||||
|
memcpy(out + 10, body, flen);
|
||||||
|
return need;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 构建整个 ID3v2.3 tag 头 + 帧;cap 不足返回 0。 */
|
||||||
|
static size_t id3_build(const ncm_tag_meta_t *m, unsigned char *out, size_t cap)
|
||||||
|
{
|
||||||
|
size_t body = 0;
|
||||||
|
unsigned char *frames = out + 10;
|
||||||
|
size_t frames_cap = cap > 10 ? cap - 10 : 0;
|
||||||
|
|
||||||
|
if (cap < 10) return 0;
|
||||||
|
if (m->title[0]) body += id3_frame(frames + body, frames_cap - body, "TIT2",
|
||||||
|
m->title, NCM_TAG_TITLE_MAX);
|
||||||
|
if (m->artist[0]) body += id3_frame(frames + body, frames_cap - body, "TPE1",
|
||||||
|
m->artist, NCM_TAG_ARTIST_MAX);
|
||||||
|
if (m->album[0]) body += id3_frame(frames + body, frames_cap - body, "TALB",
|
||||||
|
m->album, NCM_TAG_ALBUM_MAX);
|
||||||
|
if (body == 0 || body + 10 > cap || body > 0x0FFFFFFF) return 0;
|
||||||
|
out[0] = 'I'; out[1] = 'D'; out[2] = '3';
|
||||||
|
out[3] = 3; out[4] = 0; /* v2.3.0 */
|
||||||
|
out[5] = 0; /* flags */
|
||||||
|
put_synchsafe32(out + 6, (uint32_t)body);
|
||||||
|
return body + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析 ID3v2 标签区(fd 已定位到 0;hdr 为前 10 字节)。 */
|
||||||
|
static int id3_read(int fd, const unsigned char hdr[10], ncm_tag_meta_t *m)
|
||||||
|
{
|
||||||
|
int ver = hdr[3];
|
||||||
|
uint32_t taglen = synchsafe32(hdr + 6);
|
||||||
|
unsigned char *buf;
|
||||||
|
size_t cap, want, got;
|
||||||
|
size_t pos = 0;
|
||||||
|
size_t idlen = (ver == 2) ? 3 : 4;
|
||||||
|
|
||||||
|
if (ver < 2 || ver > 4) return NCM_ERR_NOENT;
|
||||||
|
want = taglen < NCM_TAG_ID3_MAX ? (size_t)taglen : (size_t)NCM_TAG_ID3_MAX;
|
||||||
|
buf = (unsigned char *)malloc(want ? want : 1);
|
||||||
|
if (!buf) return NCM_ERR_OOM;
|
||||||
|
cap = want;
|
||||||
|
if (lseek(fd, 10, SEEK_SET) < 0) { free(buf); return NCM_ERR_IO; }
|
||||||
|
got = (size_t)read_full(fd, buf, cap);
|
||||||
|
if (cap > 0 && got == 0) { free(buf); return NCM_ERR_NOENT; }
|
||||||
|
if (got < cap) cap = got;
|
||||||
|
|
||||||
|
while (pos + idlen + (ver == 2 ? 3 : 6) <= cap) {
|
||||||
|
const unsigned char *f = buf + pos;
|
||||||
|
size_t flen;
|
||||||
|
if (f[0] == 0) break; /* padding */
|
||||||
|
if (ver == 2) {
|
||||||
|
flen = get_be24(f + 3);
|
||||||
|
} else if (ver == 4) {
|
||||||
|
flen = synchsafe32(f + 4);
|
||||||
|
} else {
|
||||||
|
flen = ((size_t)f[4] << 24) | ((size_t)f[5] << 16) |
|
||||||
|
((size_t)f[6] << 8) | (size_t)f[7];
|
||||||
|
}
|
||||||
|
pos += idlen + (ver == 2 ? 3 : 6);
|
||||||
|
if (flen == 0 || flen > cap - pos) break;
|
||||||
|
{
|
||||||
|
const unsigned char *body = buf + pos;
|
||||||
|
size_t blen = flen;
|
||||||
|
char *dst = NULL;
|
||||||
|
size_t dst_cap = 0;
|
||||||
|
if ((ver == 2 && memcmp(f, "TT2", 3) == 0) ||
|
||||||
|
(ver != 2 && memcmp(f, "TIT2", 4) == 0)) { dst = m->title; dst_cap = sizeof m->title; }
|
||||||
|
else if ((ver == 2 && memcmp(f, "TP1", 3) == 0) ||
|
||||||
|
(ver != 2 && memcmp(f, "TPE1", 4) == 0)) { dst = m->artist; dst_cap = sizeof m->artist; }
|
||||||
|
else if ((ver == 2 && memcmp(f, "TAL", 3) == 0) ||
|
||||||
|
(ver != 2 && memcmp(f, "TALB", 4) == 0)) { dst = m->album; dst_cap = sizeof m->album; }
|
||||||
|
if (dst && dst[0] == 0 && blen > 1) {
|
||||||
|
unsigned int enc = body[0];
|
||||||
|
if (enc == 0) latin1_to_utf8(body + 1, blen - 1, dst, dst_cap);
|
||||||
|
else if (enc == 1) utf16_to_utf8(body + 1, blen - 1, dst, dst_cap);
|
||||||
|
else if (enc == 3) utf8_copy(dst, dst_cap, (const char *)(body + 1), blen - 1);
|
||||||
|
else utf16_to_utf8(body + 1, blen - 1, dst, dst_cap); /* 2=UTF-16BE */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += flen;
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
return (m->title[0] || m->artist[0] || m->album[0]) ? NCM_OK : NCM_ERR_NOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* FLAC(VORBIS_COMMENT) */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* 构建 VORBIS_COMMENT 元数据块(含 4 字节块头,last=1)。返回总字节数。 */
|
||||||
|
static size_t flac_comment_build(const ncm_tag_meta_t *m, unsigned char *out, size_t cap)
|
||||||
|
{
|
||||||
|
static const char *keys[3] = { "TITLE", "ARTIST", "ALBUM" };
|
||||||
|
const char *vals[3];
|
||||||
|
size_t lens[3];
|
||||||
|
size_t payload = 0, n = 0;
|
||||||
|
unsigned char *p;
|
||||||
|
const size_t vendor_len = sizeof(NCM_TAG_VENDOR) - 1;
|
||||||
|
|
||||||
|
vals[0] = m->title; lens[0] = tag_strlen(m->title, NCM_TAG_TITLE_MAX);
|
||||||
|
vals[1] = m->artist; lens[1] = tag_strlen(m->artist, NCM_TAG_ARTIST_MAX);
|
||||||
|
vals[2] = m->album; lens[2] = tag_strlen(m->album, NCM_TAG_ALBUM_MAX);
|
||||||
|
|
||||||
|
payload = 4 + vendor_len + 4;
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
if (vals[i][0]) { payload += 4 + strlen(keys[i]) + 1 + lens[i]; n++; }
|
||||||
|
if (n == 0 || payload > 0xFFFFFF || payload + 4 > cap) return 0;
|
||||||
|
|
||||||
|
out[0] = (unsigned char)(0x80 | 4); /* last + VORBIS_COMMENT */
|
||||||
|
put_be24(out + 1, (uint32_t)payload);
|
||||||
|
p = out + 4;
|
||||||
|
put_le32(p, (uint32_t)vendor_len); p += 4;
|
||||||
|
memcpy(p, NCM_TAG_VENDOR, vendor_len); p += vendor_len;
|
||||||
|
put_le32(p, (uint32_t)n); p += 4;
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
size_t klen;
|
||||||
|
if (!vals[i][0]) continue;
|
||||||
|
klen = strlen(keys[i]);
|
||||||
|
put_le32(p, (uint32_t)(klen + 1 + lens[i])); p += 4;
|
||||||
|
memcpy(p, keys[i], klen); p += klen;
|
||||||
|
*p++ = '=';
|
||||||
|
memcpy(p, vals[i], lens[i]); p += lens[i];
|
||||||
|
}
|
||||||
|
return (size_t)(p - out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析 VORBIS_COMMENT payload(UTF-8 键值,小端长度)。 */
|
||||||
|
static void flac_comment_parse(const unsigned char *buf, size_t len, ncm_tag_meta_t *m)
|
||||||
|
{
|
||||||
|
uint32_t vlen, count;
|
||||||
|
size_t pos;
|
||||||
|
if (len < 8) return;
|
||||||
|
vlen = get_le32(buf);
|
||||||
|
if (vlen > len - 8) return;
|
||||||
|
pos = 4 + (size_t)vlen;
|
||||||
|
if (pos + 4 > len) return;
|
||||||
|
count = get_le32(buf + pos);
|
||||||
|
pos += 4;
|
||||||
|
for (uint32_t i = 0; i < count && pos + 4 <= len; i++) {
|
||||||
|
uint32_t clen = get_le32(buf + pos);
|
||||||
|
const char *kv;
|
||||||
|
pos += 4;
|
||||||
|
if (clen > len - pos) break;
|
||||||
|
kv = (const char *)(buf + pos);
|
||||||
|
pos += clen;
|
||||||
|
if (clen > 6 && memcmp(kv, "TITLE=", 6) == 0) {
|
||||||
|
if (!m->title[0]) utf8_copy(m->title, sizeof m->title, kv + 6, clen - 6);
|
||||||
|
} else if (clen > 7 && memcmp(kv, "ARTIST=", 7) == 0) {
|
||||||
|
if (!m->artist[0]) utf8_copy(m->artist, sizeof m->artist, kv + 7, clen - 7);
|
||||||
|
} else if (clen > 6 && memcmp(kv, "ALBUM=", 6) == 0) {
|
||||||
|
if (!m->album[0]) utf8_copy(m->album, sizeof m->album, kv + 6, clen - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int flac_read(int fd, int64_t fsize, ncm_tag_meta_t *m)
|
||||||
|
{
|
||||||
|
uint64_t off = 4;
|
||||||
|
unsigned char *buf = (unsigned char *)malloc(NCM_TAG_CHUNK);
|
||||||
|
int rc = NCM_ERR_NOENT;
|
||||||
|
if (!buf) return NCM_ERR_OOM;
|
||||||
|
/* 最多扫 64 个元数据块(防畸形文件死循环;正常文件 ≤5 块) */
|
||||||
|
for (int guard = 0; guard < 64; guard++) {
|
||||||
|
unsigned char bh[4];
|
||||||
|
uint32_t blen;
|
||||||
|
int type;
|
||||||
|
if (off + 4 > (uint64_t)fsize) break;
|
||||||
|
if (pread_full(fd, bh, 4, off) != 4) { rc = NCM_ERR_IO; break; }
|
||||||
|
off += 4;
|
||||||
|
type = bh[0] & 0x7f;
|
||||||
|
blen = get_be24(bh + 1);
|
||||||
|
if (off + blen > (uint64_t)fsize) break;
|
||||||
|
if (type == 4) {
|
||||||
|
size_t want = blen < NCM_TAG_CHUNK ? (size_t)blen : (size_t)NCM_TAG_CHUNK;
|
||||||
|
int got = pread_full(fd, buf, want, off);
|
||||||
|
if (got > 0) flac_comment_parse(buf, (size_t)got, m);
|
||||||
|
if (m->title[0] || m->artist[0] || m->album[0]) { rc = NCM_OK; break; }
|
||||||
|
}
|
||||||
|
off += blen;
|
||||||
|
if (bh[0] & 0x80) break; /* last block */
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* 嵌入(重写文件) */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* 从当前 in 位置搬运 remaining 字节到 out(64KB 分块)。 */
|
||||||
|
static int copy_range(int in, int out, uint64_t remaining, unsigned char *buf, size_t bufsz)
|
||||||
|
{
|
||||||
|
while (remaining > 0) {
|
||||||
|
size_t want = remaining > (uint64_t)bufsz ? bufsz : (size_t)remaining;
|
||||||
|
ssize_t r = read(in, buf, want);
|
||||||
|
if (r < 0) { if (errno == EINTR) continue; return NCM_ERR_IO; }
|
||||||
|
if (r == 0) return NCM_ERR_IO; /* 源文件比 stat 短:异常 → 放弃重写 */
|
||||||
|
if (write_full(out, buf, (size_t)r) != 0) return NCM_ERR_IO;
|
||||||
|
remaining -= (uint64_t)r;
|
||||||
|
}
|
||||||
|
return NCM_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int skip_bytes(int fd, uint64_t n)
|
||||||
|
{
|
||||||
|
if (lseek(fd, (off_t)n, SEEK_CUR) < 0) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int embed_mp3(int in, int out, const ncm_tag_meta_t *m, int64_t fsize,
|
||||||
|
unsigned char *buf, size_t bufsz)
|
||||||
|
{
|
||||||
|
unsigned char hdr[10];
|
||||||
|
uint64_t audio_off = 0;
|
||||||
|
unsigned char tag[2048];
|
||||||
|
size_t taglen;
|
||||||
|
ssize_t got = read_full(in, hdr, sizeof hdr);
|
||||||
|
|
||||||
|
/* 源已带 ID3v2(部分源站 MP3 自带):跳过旧标签,避免双标签(播放器通常只读第一个)。 */
|
||||||
|
if (got == (ssize_t)sizeof hdr && memcmp(hdr, "ID3", 3) == 0 && hdr[3] >= 2 && hdr[3] <= 4) {
|
||||||
|
audio_off = 10 + (uint64_t)synchsafe32(hdr + 6);
|
||||||
|
if (audio_off > (uint64_t)fsize) audio_off = (uint64_t)fsize;
|
||||||
|
}
|
||||||
|
taglen = id3_build(m, tag, sizeof tag);
|
||||||
|
if (taglen == 0) return NCM_ERR_FULL;
|
||||||
|
if (write_full(out, tag, taglen) != 0) return NCM_ERR_IO;
|
||||||
|
if (lseek(in, 0, SEEK_SET) < 0) return NCM_ERR_IO;
|
||||||
|
if (skip_bytes(in, audio_off) != 0) return NCM_ERR_IO;
|
||||||
|
return copy_range(in, out, (uint64_t)fsize - audio_off, buf, bufsz);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int embed_flac(int in, int out, const ncm_tag_meta_t *m, int64_t fsize,
|
||||||
|
unsigned char *buf, size_t bufsz)
|
||||||
|
{
|
||||||
|
unsigned char magic[4];
|
||||||
|
unsigned char blk[2048];
|
||||||
|
size_t blen;
|
||||||
|
uint64_t cur;
|
||||||
|
|
||||||
|
if (read_full(in, magic, sizeof magic) != (ssize_t)sizeof magic ||
|
||||||
|
memcmp(magic, "fLaC", 4) != 0)
|
||||||
|
return NCM_ERR_ARG; /* 非 FLAC(magic 不符):降级 */
|
||||||
|
if (write_full(out, magic, 4) != 0) return NCM_ERR_IO;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
unsigned char bh[4];
|
||||||
|
int last, type;
|
||||||
|
uint32_t blen_payload;
|
||||||
|
if (read_full(in, bh, sizeof bh) != (ssize_t)sizeof bh) return NCM_ERR_ARG;
|
||||||
|
last = (bh[0] & 0x80) != 0;
|
||||||
|
type = bh[0] & 0x7f;
|
||||||
|
blen_payload = get_be24(bh + 1);
|
||||||
|
if (type == 127) return NCM_ERR_ARG; /* 非法块类型 */
|
||||||
|
if (type == 4) {
|
||||||
|
/* 旧 VORBIS_COMMENT:跳过(同键重复时播放器只取第一个,保留旧的等于没写) */
|
||||||
|
if (skip_bytes(in, blen_payload) != 0) return NCM_ERR_IO;
|
||||||
|
} else {
|
||||||
|
bh[0] = (unsigned char)type; /* 清 last:后面还要接本模块的块 */
|
||||||
|
if (write_full(out, bh, sizeof bh) != 0) return NCM_ERR_IO;
|
||||||
|
if (copy_range(in, out, blen_payload, buf, bufsz) != NCM_OK) return NCM_ERR_IO;
|
||||||
|
}
|
||||||
|
if (last) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blen = flac_comment_build(m, blk, sizeof blk);
|
||||||
|
if (blen == 0) return NCM_ERR_FULL;
|
||||||
|
if (write_full(out, blk, blen) != 0) return NCM_ERR_IO;
|
||||||
|
|
||||||
|
/* 余下即音频帧:原样搬运到 EOF */
|
||||||
|
{
|
||||||
|
off_t c = lseek(in, 0, SEEK_CUR);
|
||||||
|
if (c < 0) return NCM_ERR_IO;
|
||||||
|
cur = (uint64_t)c;
|
||||||
|
if (cur > (uint64_t)fsize) return NCM_ERR_IO;
|
||||||
|
return copy_range(in, out, (uint64_t)fsize - cur, buf, bufsz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t *m)
|
||||||
|
{
|
||||||
|
char tmp[NCM_TAG_PATH_MAX];
|
||||||
|
struct stat st;
|
||||||
|
unsigned char magic[12];
|
||||||
|
unsigned char *buf;
|
||||||
|
int in, out, rc, is_flac, n;
|
||||||
|
ssize_t mgot;
|
||||||
|
|
||||||
|
if (!path || !path[0] || !m) return NCM_ERR_ARG;
|
||||||
|
if (!m->title[0] && !m->artist[0] && !m->album[0]) return NCM_OK; /* 无元数据:不动文件 */
|
||||||
|
/* ext 只作合法性提示;**容器以文件 magic 为准**——缓存名里的档位段是请求档,
|
||||||
|
* 实际格式可能被降级链换掉(song_<id>_hires.mp3 真机实证),绝不能按名字判容器。 */
|
||||||
|
if (ext && !(strcasecmp(ext, "flac") == 0 || strcasecmp(ext, "mp3") == 0))
|
||||||
|
return NCM_ERR_ARG;
|
||||||
|
|
||||||
|
n = snprintf(tmp, sizeof tmp, "%s.tag.tmp", path);
|
||||||
|
if (n <= 0 || (size_t)n >= sizeof tmp) return NCM_ERR_FULL;
|
||||||
|
|
||||||
|
in = open(path, O_RDONLY);
|
||||||
|
if (in < 0) return NCM_ERR_IO;
|
||||||
|
if (fstat(in, &st) != 0 || st.st_size <= 0) { close(in); return NCM_ERR_IO; }
|
||||||
|
|
||||||
|
mgot = read_full(in, magic, sizeof magic);
|
||||||
|
if (mgot < 4) { close(in); return NCM_ERR_IO; }
|
||||||
|
if (lseek(in, 0, SEEK_SET) < 0) { close(in); return NCM_ERR_IO; }
|
||||||
|
is_flac = (memcmp(magic, "fLaC", 4) == 0);
|
||||||
|
/* 声明 flac 却无 fLaC 魔数 = 残体/损坏:拒绝写(调用方 commit 裸流降级)。
|
||||||
|
* 声明 mp3 而内容实为 FLAC:按容器写 VORBIS_COMMENT(真机降级产物同理)。
|
||||||
|
* MP4/M4A(第 4..7 字节 "ftyp"):ID3v2 头会破坏 MP4 容器 → 拒绝(裸流降级)。 */
|
||||||
|
if (!is_flac && ext && strcasecmp(ext, "flac") == 0) { close(in); return NCM_ERR_ARG; }
|
||||||
|
if (!is_flac && mgot >= 8 && memcmp(magic + 4, "ftyp", 4) == 0) { close(in); return NCM_ERR_ARG; }
|
||||||
|
|
||||||
|
buf = (unsigned char *)malloc(NCM_TAG_CHUNK);
|
||||||
|
if (!buf) { close(in); return NCM_ERR_OOM; }
|
||||||
|
|
||||||
|
out = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||||
|
if (out < 0) { free(buf); close(in); return NCM_ERR_IO; }
|
||||||
|
|
||||||
|
rc = is_flac ? embed_flac(in, out, m, (int64_t)st.st_size, buf, NCM_TAG_CHUNK)
|
||||||
|
: embed_mp3(in, out, m, (int64_t)st.st_size, buf, NCM_TAG_CHUNK);
|
||||||
|
if (rc == NCM_OK && fsync(out) != 0) rc = NCM_ERR_IO;
|
||||||
|
if (close(out) != 0 && rc == NCM_OK) rc = NCM_ERR_IO;
|
||||||
|
close(in);
|
||||||
|
free(buf);
|
||||||
|
|
||||||
|
if (rc == NCM_OK) {
|
||||||
|
if (rename(tmp, path) != 0) { /* 原子替换(同目录) */
|
||||||
|
unlink(tmp);
|
||||||
|
rc = NCM_ERR_IO;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unlink(tmp); /* 失败:原文件保持原样 */
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ncm_tag_read_file(const char *path, ncm_tag_meta_t *m)
|
||||||
|
{
|
||||||
|
unsigned char hdr[10];
|
||||||
|
struct stat st;
|
||||||
|
int fd, rc = NCM_ERR_NOENT;
|
||||||
|
ssize_t got;
|
||||||
|
|
||||||
|
if (!path || !m) return NCM_ERR_ARG;
|
||||||
|
memset(m, 0, sizeof *m);
|
||||||
|
fd = open(path, O_RDONLY);
|
||||||
|
if (fd < 0) return NCM_ERR_NOENT;
|
||||||
|
if (fstat(fd, &st) != 0 || st.st_size < 4) { close(fd); return NCM_ERR_NOENT; }
|
||||||
|
got = read_full(fd, hdr, sizeof hdr);
|
||||||
|
if (got == (ssize_t)sizeof hdr && memcmp(hdr, "ID3", 3) == 0)
|
||||||
|
rc = id3_read(fd, hdr, m);
|
||||||
|
else if (got >= 4 && memcmp(hdr, "fLaC", 4) == 0)
|
||||||
|
rc = flac_read(fd, (int64_t)st.st_size, m);
|
||||||
|
close(fd);
|
||||||
|
if (rc == NCM_OK && !m->title[0] && !m->artist[0] && !m->album[0])
|
||||||
|
rc = NCM_ERR_NOENT;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* 音频头探测(实际容器/实际档位判定;不解码) */
|
||||||
|
/* ============================================================ */
|
||||||
|
|
||||||
|
/* MPEG Layer III 码率表(kbps):MPEG1 / MPEG2&2.5 */
|
||||||
|
static const int k_br_mpeg1_l3[16] = { 0, 32, 40, 48, 56, 64, 80, 96,
|
||||||
|
112, 128, 160, 192, 224, 256, 320, 0 };
|
||||||
|
static const int k_br_mpeg2_l3[16] = { 0, 8, 16, 24, 32, 40, 48, 56,
|
||||||
|
64, 80, 96, 112, 128, 144, 160, 0 };
|
||||||
|
/* 采样率:MPEG1 / MPEG2 / MPEG2.5 */
|
||||||
|
static const int k_sr[3][3] = { { 44100, 48000, 32000 },
|
||||||
|
{ 22050, 24000, 16000 },
|
||||||
|
{ 11025, 12000, 8000 } };
|
||||||
|
|
||||||
|
/* 从一段字节里找首个合法 MPEG Layer III 帧头,回填码率/采样率。返回 1=找到。 */
|
||||||
|
static int mp3_find_frame(const unsigned char *b, size_t n, int *br_kbps, int *sample_rate)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i + 4 <= n; i++) {
|
||||||
|
unsigned int v, layer, br_idx, sr_idx, pad;
|
||||||
|
int version;
|
||||||
|
if (b[i] != 0xFF || (b[i + 1] & 0xE0) != 0xE0) continue;
|
||||||
|
v = (b[i + 1] >> 3) & 0x03;
|
||||||
|
layer = (b[i + 1] >> 1) & 0x03;
|
||||||
|
if (v == 1 || layer == 0) continue; /* reserved */
|
||||||
|
br_idx = (b[i + 2] >> 4) & 0x0F;
|
||||||
|
sr_idx = (b[i + 2] >> 2) & 0x03;
|
||||||
|
pad = (b[i + 2] >> 1) & 0x01;
|
||||||
|
if (br_idx == 0 || br_idx == 15 || sr_idx == 3) continue;
|
||||||
|
(void)pad;
|
||||||
|
version = (v == 3) ? 0 : ((v == 2) ? 1 : 2);
|
||||||
|
*sample_rate = k_sr[version][sr_idx];
|
||||||
|
/* 仅 Layer III 有精确码率表;其它层按同表近似(网易云实际均为 Layer III) */
|
||||||
|
*br_kbps = (v == 3) ? k_br_mpeg1_l3[br_idx] : k_br_mpeg2_l3[br_idx];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ncm_audio_probe_file(const char *path, ncm_audio_info_t *out)
|
||||||
|
{
|
||||||
|
unsigned char hdr[10];
|
||||||
|
struct stat st;
|
||||||
|
int fd, rc = NCM_ERR_ARG;
|
||||||
|
ssize_t got;
|
||||||
|
|
||||||
|
if (!path || !out) return NCM_ERR_ARG;
|
||||||
|
memset(out, 0, sizeof *out);
|
||||||
|
fd = open(path, O_RDONLY);
|
||||||
|
if (fd < 0) return NCM_ERR_NOENT;
|
||||||
|
if (fstat(fd, &st) != 0 || st.st_size < 10) { close(fd); return NCM_ERR_NOENT; }
|
||||||
|
got = read_full(fd, hdr, sizeof hdr);
|
||||||
|
if (got < 4) { close(fd); return NCM_ERR_NOENT; }
|
||||||
|
|
||||||
|
if (memcmp(hdr, "fLaC", 4) == 0) {
|
||||||
|
unsigned char bh[4], si[34];
|
||||||
|
if (pread_full(fd, bh, sizeof bh, 4) != (int)sizeof bh) { close(fd); return NCM_ERR_IO; }
|
||||||
|
if ((bh[0] & 0x7f) != 0) { close(fd); return NCM_ERR_ARG; } /* 首块必为 STREAMINFO */
|
||||||
|
if (pread_full(fd, si, sizeof si, 8) != (int)sizeof si) { close(fd); return NCM_ERR_IO; }
|
||||||
|
snprintf(out->container, sizeof out->container, "flac");
|
||||||
|
out->sample_rate = ((int)si[10] << 12) | ((int)si[11] << 4) | ((int)si[12] >> 4);
|
||||||
|
out->bits_per_sample = (int)((((unsigned)si[12] & 0x01) << 4) |
|
||||||
|
((unsigned)si[13] >> 4)) + 1;
|
||||||
|
if (out->sample_rate <= 0 || out->bits_per_sample <= 0) out->sample_rate = 0;
|
||||||
|
rc = NCM_OK;
|
||||||
|
} else {
|
||||||
|
/* 跳过 ID3v2(若有),在音频区前 64KB 找首帧 */
|
||||||
|
unsigned char *buf;
|
||||||
|
uint64_t off = 0;
|
||||||
|
size_t want;
|
||||||
|
int got2;
|
||||||
|
if (got == (ssize_t)sizeof hdr && memcmp(hdr, "ID3", 3) == 0 &&
|
||||||
|
hdr[3] >= 2 && hdr[3] <= 4)
|
||||||
|
off = 10 + (uint64_t)synchsafe32(hdr + 6);
|
||||||
|
if (off >= (uint64_t)st.st_size) { close(fd); return NCM_ERR_ARG; }
|
||||||
|
want = (size_t)((uint64_t)st.st_size - off);
|
||||||
|
if (want > NCM_TAG_CHUNK) want = NCM_TAG_CHUNK;
|
||||||
|
buf = (unsigned char *)malloc(want ? want : 1);
|
||||||
|
if (!buf) { close(fd); return NCM_ERR_OOM; }
|
||||||
|
got2 = pread_full(fd, buf, want, off);
|
||||||
|
if (got2 > 0) {
|
||||||
|
int br = 0, sr = 0;
|
||||||
|
if (mp3_find_frame(buf, (size_t)got2, &br, &sr)) {
|
||||||
|
snprintf(out->container, sizeof out->container, "mp3");
|
||||||
|
out->bitrate_kbps = br;
|
||||||
|
out->sample_rate = sr;
|
||||||
|
out->bits_per_sample = 16;
|
||||||
|
rc = NCM_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(buf);
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/* ncm_tag.h — 缓存音频文件内的歌曲元数据嵌入(纯 libc,宿主可单测)
|
||||||
|
*
|
||||||
|
* 背景(NCM_CACHE_DESIGN §2.5 + 用户 2026-11-04 需求):缓存文件原为裸音频流,外部播放器
|
||||||
|
* 读不到任何信息。本模块在**缓存落盘时**把歌曲元数据写进文件本身:
|
||||||
|
* FLAC → VORBIS_COMMENT 元数据块(type 4,插在最后一个既有元数据块之后、音频帧之前)
|
||||||
|
* MP3 → ID3v2.3 标签头(TIT2/TPE1/TALB,UTF-16LE + BOM,中英文均可)
|
||||||
|
* **按实际容器分支**(读文件 magic):缓存名是 song_<id>_<请求档位>.<实际格式>,档位段是
|
||||||
|
* 请求档、不代表容器(真机存在 song_171650_hires.mp3:无损档请求被降级链落到 exhigh mp3);
|
||||||
|
* 故写 tag 只认 "fLaC" 魔数,其余按 MP3 处理,ext 仅作合法性提示。
|
||||||
|
* 与既有的元数据分离体系(<cache_dir>/meta/<id>.bin 旁路 + 索引)并存,互不影响:
|
||||||
|
* 旁路照旧写、照旧读;文件内 tag 只是「给外部播放器看」的副本。
|
||||||
|
*
|
||||||
|
* 另提供 ncm_audio_probe_file:只读文件头探测实际容器参数(FLAC bps/采样率、MP3 首帧码率),
|
||||||
|
* 供「已缓存列表」显示**实际档位**(不是请求档)。
|
||||||
|
*
|
||||||
|
* 写入方式:同目录临时文件 + 分块拷贝(64KB 堆缓冲)+ rename 原子替换。
|
||||||
|
* - 全程不整文件读入内存,单次峰值 = NCM_TAG_CHUNK(64KB);
|
||||||
|
* - 先落盘 fsync 再 rename,任一步失败都 unlink 临时文件、**原文件保持不变**,
|
||||||
|
* 调用方据返回值降级为裸流(播放主链路不受影响)。
|
||||||
|
* - 解析仅在文件头(tag 区),音频字节原样搬运(逐字节不变)。
|
||||||
|
*/
|
||||||
|
#ifndef NCM_TAG_H
|
||||||
|
#define NCM_TAG_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define NCM_TAG_CHUNK (64 * 1024) /* 分块缓冲(峰值内存来源;设备 56MB,64KB 足够) */
|
||||||
|
#define NCM_TAG_TITLE_MAX 128
|
||||||
|
#define NCM_TAG_ARTIST_MAX 96
|
||||||
|
#define NCM_TAG_ALBUM_MAX 96
|
||||||
|
|
||||||
|
typedef struct ncm_tag_meta {
|
||||||
|
char title[NCM_TAG_TITLE_MAX];
|
||||||
|
char artist[NCM_TAG_ARTIST_MAX];
|
||||||
|
char album[NCM_TAG_ALBUM_MAX];
|
||||||
|
} ncm_tag_meta_t;
|
||||||
|
|
||||||
|
/* 把 path 重写为「tag 前缀 + 原音频字节」(mp3=ID3v2.3 头,flac=VORBIS_COMMENT 块)。
|
||||||
|
* **容器按文件 magic 判定**("fLaC" → FLAC,其余 → MP3),ext 只作合法性提示/回退。
|
||||||
|
* 三个字段全空 → 不写 tag、直接返回 NCM_OK(文件不动)。
|
||||||
|
* 返回 NCM_OK=成功(或无需写);NCM_ERR_ARG=参数/格式不支持;NCM_ERR_IO=读写失败;
|
||||||
|
* NCM_ERR_OOM=缓冲分配失败。失败时原文件内容保持原样。 */
|
||||||
|
int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t *m);
|
||||||
|
|
||||||
|
/* 读取文件内 tag(FLAC VORBIS_COMMENT / MP3 ID3v2.3/2.4)。字段可为 NULL。
|
||||||
|
* 返回 NCM_OK=命中;NCM_ERR_NOENT=无 tag/文件不存在;NCM_ERR_IO=读失败。
|
||||||
|
* 只读文件头(≤ NCM_TAG_CHUNK),不整读。 */
|
||||||
|
int ncm_tag_read_file(const char *path, ncm_tag_meta_t *m);
|
||||||
|
|
||||||
|
/* 音频头探测(不解码、只读文件头):判定**实际容器**与实际参数。
|
||||||
|
* FLAC 读 STREAMINFO(采样率/位深);MP3 跳过 ID3v2 后读首个帧头(码率/采样率)。
|
||||||
|
* 返回 NCM_OK=识别出容器;NCM_ERR_ARG=未知容器;NCM_ERR_NOENT/IO=读失败。 */
|
||||||
|
typedef struct ncm_audio_info {
|
||||||
|
char container[8]; /* "flac" / "mp3" / "" */
|
||||||
|
int bits_per_sample; /* FLAC=STREAMINFO bps;MP3=16(有损) */
|
||||||
|
int sample_rate; /* Hz;0=未知 */
|
||||||
|
int bitrate_kbps; /* MP3 首帧码率;FLAC=0 */
|
||||||
|
} ncm_audio_info_t;
|
||||||
|
|
||||||
|
int ncm_audio_probe_file(const char *path, ncm_audio_info_t *out);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif /* NCM_TAG_H */
|
||||||
@@ -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_<id>_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) {
|
static void test_qr(void) {
|
||||||
printf("== qrcodegen ==\n");
|
printf("== qrcodegen ==\n");
|
||||||
unsigned char mods[177 * 177];
|
unsigned char mods[177 * 177];
|
||||||
@@ -541,6 +678,7 @@ int main(int argc, char **argv) {
|
|||||||
test_crypto_vectors();
|
test_crypto_vectors();
|
||||||
test_store_cookie();
|
test_store_cookie();
|
||||||
test_cache();
|
test_cache();
|
||||||
|
test_cache_tag();
|
||||||
test_qr();
|
test_qr();
|
||||||
test_cookie_merge();
|
test_cookie_merge();
|
||||||
test_eapi_musicu_hdr();
|
test_eapi_musicu_hdr();
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
/* test/test_ncm_tag.c — 缓存文件内元数据嵌入宿主单测(纯 libc,不依赖 mbedtls/jsmn)。
|
||||||
|
*
|
||||||
|
* 覆盖(NCM_TAG_CHUNK=64KB 分块重写):
|
||||||
|
* ① FLAC:无 tag / 已有 VORBIS_COMMENT + PADDING 两种源 → 嵌入后块结构合法、
|
||||||
|
* VORBIS_COMMENT 唯一位于元数据末块、三项键值可读回、音频帧字节逐字节不变;
|
||||||
|
* ② MP3:裸流 / 已带 ID3v2.3 旧标签 两种源 → 只留一个 ID3v2.3 头、TIT2/TPE1/TALB 可读回、
|
||||||
|
* 旧标签被替换、音频字节不变;
|
||||||
|
* ③ 中文标题(UTF-8 → UTF-16LE → UTF-8)roundtrip;空元数据不动文件;
|
||||||
|
* ④ 大文件(3MB)分块搬运:音频段逐字节一致(峰值内存=64KB,见 ncm_tag.c 头注释);
|
||||||
|
* ⑤ 失败降级:.flac 扩展名但 magic 不符 / 文件不存在 → 返回错误、原文件不变、无临时残体。
|
||||||
|
*
|
||||||
|
* 编译/运行:./build_ncm_tag_host.sh && ./test/test_ncm_tag_host
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "ncm_common.h"
|
||||||
|
#include "ncm_tag.h"
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#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)
|
||||||
|
|
||||||
|
/* 相对 CWD 的测试目录:main 先 chdir 到 $TMPDIR(Termux 的 /tmp 不可写),再建本目录。 */
|
||||||
|
#define TMPDIR_PREFIX "ncm_tag_test"
|
||||||
|
|
||||||
|
static void mkdir_tmp(void)
|
||||||
|
{
|
||||||
|
char cmd[256];
|
||||||
|
snprintf(cmd, sizeof cmd, "rm -rf %s; mkdir -p %s", TMPDIR_PREFIX, TMPDIR_PREFIX);
|
||||||
|
if (system(cmd) != 0) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
static long file_size(const char *p)
|
||||||
|
{
|
||||||
|
struct stat st;
|
||||||
|
if (stat(p, &st) != 0) return -1;
|
||||||
|
return (long)st.st_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned char *file_slurp(const char *p, size_t *len_out)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(p, "rb");
|
||||||
|
unsigned char *buf;
|
||||||
|
long n;
|
||||||
|
if (!f) return NULL;
|
||||||
|
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return NULL; }
|
||||||
|
n = ftell(f);
|
||||||
|
if (n < 0 || fseek(f, 0, SEEK_SET) != 0) { fclose(f); return NULL; }
|
||||||
|
buf = (unsigned char *)malloc((size_t)n ? (size_t)n : 1);
|
||||||
|
if (!buf) { fclose(f); return NULL; }
|
||||||
|
if (n > 0 && fread(buf, 1, (size_t)n, f) != (size_t)n) { free(buf); fclose(f); return NULL; }
|
||||||
|
fclose(f);
|
||||||
|
*len_out = (size_t)n;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int file_put(const char *p, const void *buf, size_t len)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(p, "wb");
|
||||||
|
if (!f) return -1;
|
||||||
|
if (len && fwrite(buf, 1, len, f) != len) { fclose(f); return -1; }
|
||||||
|
fclose(f);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int file_append(const char *p, const void *buf, size_t len)
|
||||||
|
{
|
||||||
|
FILE *f = fopen(p, "ab");
|
||||||
|
if (!f) return -1;
|
||||||
|
if (len && fwrite(buf, 1, len, f) != len) { fclose(f); return -1; }
|
||||||
|
fclose(f);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 逐字节比较两段内存是否相同;返回首个不同位置(-1=全同) */
|
||||||
|
static long mem_diff(const unsigned char *a, const unsigned char *b, size_t n)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < n; i++) if (a[i] != b[i]) return (long)i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 样本构造:最小合法 FLAC(STREAMINFO 34B + 可选附加块 + 音频帧) ---- */
|
||||||
|
static void flac_streaminfo(unsigned char out[34], int bps)
|
||||||
|
{
|
||||||
|
memset(out, 0, 34);
|
||||||
|
out[0] = 0x10; out[1] = 0x00; /* min block 4096 */
|
||||||
|
out[2] = 0x10; out[3] = 0x00; /* max block 4096 */
|
||||||
|
/* 8 字节:sample_rate 44100 / ch 2 / bps / total_samples 44100 */
|
||||||
|
out[10] = 0x0A; out[11] = 0xC4;
|
||||||
|
out[12] = (unsigned char)(0x40 | 0x02 | (((bps - 1) >> 4) & 0x01));
|
||||||
|
out[13] = (unsigned char)(((bps - 1) & 0x0F) << 4);
|
||||||
|
out[14] = 0x00; out[15] = 0x00;
|
||||||
|
out[16] = 0xAC; out[17] = 0x44; /* total samples 44100 */
|
||||||
|
}
|
||||||
|
|
||||||
|
static int flac_make(const char *path, int with_old_tag, size_t audio_len, unsigned char seed,
|
||||||
|
int bps)
|
||||||
|
{
|
||||||
|
unsigned char si[34];
|
||||||
|
unsigned char hdr[4];
|
||||||
|
flac_streaminfo(si, bps);
|
||||||
|
file_put(path, "fLaC", 4);
|
||||||
|
hdr[0] = (with_old_tag ? 0x00 : 0x80); /* last = 无附加块 */
|
||||||
|
hdr[1] = 0x00; hdr[2] = 0x00; hdr[3] = 34;
|
||||||
|
file_append(path, hdr, 4);
|
||||||
|
file_append(path, si, 34);
|
||||||
|
if (with_old_tag) {
|
||||||
|
/* 旧 VORBIS_COMMENT(应被替换) */
|
||||||
|
static const char old_c[] = "TITLE=old";
|
||||||
|
unsigned char blk[64];
|
||||||
|
size_t vlen = 9, plen = 4 + vlen + 4 + 4 + sizeof(old_c) - 1;
|
||||||
|
memset(blk, 0, sizeof blk);
|
||||||
|
blk[0] = 0x04; /* type 4, 非 last */
|
||||||
|
blk[1] = 0; blk[2] = 0; blk[3] = (unsigned char)plen;
|
||||||
|
blk[4] = (unsigned char)vlen; /* vendor_len LE */
|
||||||
|
memcpy(blk + 8, "MeowOSold", vlen);
|
||||||
|
blk[8 + vlen] = 1; /* count = 1 */
|
||||||
|
blk[12 + vlen] = (unsigned char)(sizeof(old_c) - 1);
|
||||||
|
memcpy(blk + 16 + vlen, old_c, sizeof(old_c) - 1);
|
||||||
|
file_append(path, blk, 4 + plen);
|
||||||
|
/* PADDING(type 1,last) */
|
||||||
|
blk[0] = 0x81; blk[1] = 0; blk[2] = 0; blk[3] = 8;
|
||||||
|
file_append(path, blk, 4);
|
||||||
|
file_append(path, " ", 8);
|
||||||
|
}
|
||||||
|
/* 假音频帧(内容只需逐字节不变) */
|
||||||
|
for (size_t i = 0; i < audio_len; i++) {
|
||||||
|
unsigned char b = (unsigned char)(seed + (unsigned char)(i * 31u));
|
||||||
|
if (file_append(path, &b, 1) != 0) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫描 FLAC 块表:返回 VORBIS_COMMENT 的 payload 偏移/长度、音频帧起始;失败返回 -1 */
|
||||||
|
static int flac_scan(const unsigned char *buf, size_t len, size_t *vc_off, size_t *vc_len,
|
||||||
|
size_t *audio_off, int *n_vc, int *last_ok)
|
||||||
|
{
|
||||||
|
size_t off = 4;
|
||||||
|
int nvc = 0;
|
||||||
|
if (len < 8 || memcmp(buf, "fLaC", 4) != 0) return -1;
|
||||||
|
*vc_off = 0; *vc_len = 0; *n_vc = 0; *last_ok = 0;
|
||||||
|
for (int guard = 0; guard < 64; guard++) {
|
||||||
|
unsigned char h;
|
||||||
|
size_t bl;
|
||||||
|
if (off + 4 > len) return -1;
|
||||||
|
h = buf[off];
|
||||||
|
bl = ((size_t)buf[off + 1] << 16) | ((size_t)buf[off + 2] << 8) | buf[off + 3];
|
||||||
|
off += 4;
|
||||||
|
if (off + bl > len) return -1;
|
||||||
|
if ((h & 0x7f) == 4) { nvc++; *vc_off = off; *vc_len = bl; }
|
||||||
|
off += bl;
|
||||||
|
if (h & 0x80) {
|
||||||
|
*last_ok = ((h & 0x7f) == 4) ? 1 : 0;
|
||||||
|
*audio_off = off;
|
||||||
|
*n_vc = nvc;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_flac(void)
|
||||||
|
{
|
||||||
|
ncm_tag_meta_t m, got;
|
||||||
|
char path[256];
|
||||||
|
unsigned char *before, *after;
|
||||||
|
size_t blen, alen, vc_off, vc_len, audio_off;
|
||||||
|
int nvc = 0, last_ok = 0;
|
||||||
|
|
||||||
|
printf("== FLAC VORBIS_COMMENT ==\n");
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
snprintf(m.title, sizeof m.title, "夜曲");
|
||||||
|
snprintf(m.artist, sizeof m.artist, "周杰伦");
|
||||||
|
snprintf(m.album, sizeof m.album, "十一月的萧邦");
|
||||||
|
|
||||||
|
/* ① 裸 FLAC(无旧 tag) */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/a.flac");
|
||||||
|
CHECK(flac_make(path, 0, 1024, 7, 16) == 0, "build minimal flac");
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "flac", &m) == 0, "embed flac (no old tag)");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
CHECK(before && after && alen > blen, "file grew by tag block (%zu -> %zu)", blen, alen);
|
||||||
|
CHECK(flac_scan(after, alen, &vc_off, &vc_len, &audio_off, &nvc, &last_ok) == 0,
|
||||||
|
"metadata block table parses");
|
||||||
|
CHECK(nvc == 1 && last_ok == 1, "exactly one VORBIS_COMMENT, last block (n=%d last=%d)",
|
||||||
|
nvc, last_ok);
|
||||||
|
CHECK(alen - audio_off == 1024 &&
|
||||||
|
mem_diff(after + audio_off, before + blen - 1024, 1024) < 0,
|
||||||
|
"audio frames preserved byte-for-byte");
|
||||||
|
memset(&got, 0, sizeof got);
|
||||||
|
CHECK(ncm_tag_read_file(path, &got) == 0, "read back embedded tag");
|
||||||
|
CHECK(strcmp(got.title, "夜曲") == 0, "title roundtrip UTF-8 (got: %s)", got.title);
|
||||||
|
CHECK(strcmp(got.artist, "周杰伦") == 0, "artist roundtrip (got: %s)", got.artist);
|
||||||
|
CHECK(strcmp(got.album, "十一月的萧邦") == 0, "album roundtrip (got: %s)", got.album);
|
||||||
|
free(before); free(after);
|
||||||
|
|
||||||
|
/* ② 已有旧 VORBIS_COMMENT + PADDING:旧块被剔除,只留我们的,PADDING 保留 */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/b.flac");
|
||||||
|
CHECK(flac_make(path, 1, 2048, 11, 16) == 0, "build flac with old tag + padding");
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "flac", &m) == 0, "embed flac (old tag)");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
CHECK(flac_scan(after, alen, &vc_off, &vc_len, &audio_off, &nvc, &last_ok) == 0 &&
|
||||||
|
nvc == 1 && last_ok == 1,
|
||||||
|
"old VORBIS_COMMENT replaced (n=%d)", nvc);
|
||||||
|
CHECK(alen - audio_off == 2048 &&
|
||||||
|
mem_diff(after + audio_off, before + blen - 2048, 2048) < 0,
|
||||||
|
"audio frames preserved (with old tag source)");
|
||||||
|
memset(&got, 0, sizeof got);
|
||||||
|
CHECK(ncm_tag_read_file(path, &got) == 0 && strcmp(got.title, "夜曲") == 0,
|
||||||
|
"read back new title (got: %s)", got.title);
|
||||||
|
/* 旧 title "old" 不应残留 */
|
||||||
|
{
|
||||||
|
int has_old = 0;
|
||||||
|
for (size_t i = 0; i + 3 <= vc_len; i++)
|
||||||
|
if (memcmp(after + vc_off + i, "old", 3) == 0) has_old = 1;
|
||||||
|
CHECK(!has_old, "old tag value removed from VORBIS_COMMENT");
|
||||||
|
}
|
||||||
|
free(before); free(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_mp3(void)
|
||||||
|
{
|
||||||
|
ncm_tag_meta_t m, got;
|
||||||
|
char path[256];
|
||||||
|
unsigned char *before, *after;
|
||||||
|
size_t blen, alen;
|
||||||
|
size_t audio_before = 0, audio_after;
|
||||||
|
|
||||||
|
printf("== MP3 ID3v2.3 ==\n");
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
snprintf(m.title, sizeof m.title, "晴天 (Live)");
|
||||||
|
snprintf(m.artist, sizeof m.artist, "Jay Chou");
|
||||||
|
snprintf(m.album, sizeof m.album, "叶惠美");
|
||||||
|
|
||||||
|
/* ① 裸 MP3 流(无 ID3) */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/a.mp3");
|
||||||
|
{
|
||||||
|
unsigned char frame[512];
|
||||||
|
for (size_t i = 0; i < sizeof frame; i++) frame[i] = (unsigned char)(0xF0 ^ (i * 7));
|
||||||
|
frame[0] = 0xFF; frame[1] = 0xFB; /* MPEG1 Layer III */
|
||||||
|
frame[2] = 0xE0; frame[3] = 0x00; /* 320kbps / 44.1kHz */
|
||||||
|
file_put(path, frame, sizeof frame);
|
||||||
|
}
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) == 0, "embed mp3 (raw stream)");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
CHECK(before && after && alen > blen, "file grew by ID3v2.3 header (%zu -> %zu)", blen, alen);
|
||||||
|
CHECK(alen - blen >= 10 && memcmp(after, "ID3", 3) == 0 && after[3] == 3,
|
||||||
|
"starts with ID3v2.3 header");
|
||||||
|
{
|
||||||
|
size_t tsize = ((size_t)(after[6] & 0x7f) << 21) | ((size_t)(after[7] & 0x7f) << 14) |
|
||||||
|
((size_t)(after[8] & 0x7f) << 7) | (size_t)(after[9] & 0x7f);
|
||||||
|
audio_after = 10 + tsize;
|
||||||
|
CHECK(audio_after == alen - blen, "ID3 size field matches appended header (%zu)",
|
||||||
|
audio_after);
|
||||||
|
}
|
||||||
|
CHECK(mem_diff(after + audio_after, before, blen) < 0, "audio bytes preserved (raw source)");
|
||||||
|
memset(&got, 0, sizeof got);
|
||||||
|
CHECK(ncm_tag_read_file(path, &got) == 0, "read back embedded mp3 tag");
|
||||||
|
CHECK(strcmp(got.title, "晴天 (Live)") == 0, "title roundtrip (got: %s)", got.title);
|
||||||
|
CHECK(strcmp(got.artist, "Jay Chou") == 0, "artist roundtrip (got: %s)", got.artist);
|
||||||
|
CHECK(strcmp(got.album, "叶惠美") == 0, "album roundtrip (got: %s)", got.album);
|
||||||
|
free(before); free(after);
|
||||||
|
|
||||||
|
/* ② 已带 ID3v2.3 旧标签:旧标签被跳过(不留双标签),音频段不变 */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/b.mp3");
|
||||||
|
{
|
||||||
|
unsigned char old[64];
|
||||||
|
unsigned char frame[256];
|
||||||
|
size_t body = 10 + 1 + 2 + 2 * 3; /* TIT2 "old" UTF-16LE */
|
||||||
|
memset(old, 0, sizeof old);
|
||||||
|
memcpy(old, "ID3", 3); old[3] = 3;
|
||||||
|
old[6] = (unsigned char)((body >> 21) & 0x7f);
|
||||||
|
old[7] = (unsigned char)((body >> 14) & 0x7f);
|
||||||
|
old[8] = (unsigned char)((body >> 7) & 0x7f);
|
||||||
|
old[9] = (unsigned char)(body & 0x7f);
|
||||||
|
memcpy(old + 10, "TIT2", 4);
|
||||||
|
old[14] = 0; old[15] = 0; old[16] = 0; old[17] = 1 + 2 + 6;
|
||||||
|
old[18] = 0; old[19] = 0;
|
||||||
|
old[20] = 1; old[21] = 0xFF; old[22] = 0xFE;
|
||||||
|
old[23] = 'o'; old[24] = 0; old[25] = 'l'; old[26] = 0; old[27] = 'd'; old[28] = 0;
|
||||||
|
for (size_t i = 0; i < sizeof frame; i++) frame[i] = (unsigned char)(i * 13u + 3u);
|
||||||
|
frame[0] = 0xFF; frame[1] = 0xFB;
|
||||||
|
frame[2] = 0xE0; frame[3] = 0x00; /* 320kbps / 44.1kHz */
|
||||||
|
file_put(path, old, 10 + body);
|
||||||
|
file_append(path, frame, sizeof frame);
|
||||||
|
audio_before = 10 + body;
|
||||||
|
}
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) == 0, "embed mp3 (existing old tag)");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
{
|
||||||
|
size_t tsize = ((size_t)(after[6] & 0x7f) << 21) | ((size_t)(after[7] & 0x7f) << 14) |
|
||||||
|
((size_t)(after[8] & 0x7f) << 7) | (size_t)(after[9] & 0x7f);
|
||||||
|
audio_after = 10 + tsize;
|
||||||
|
CHECK(alen - audio_after == blen - audio_before &&
|
||||||
|
mem_diff(after + audio_after, before + audio_before, blen - audio_before) < 0,
|
||||||
|
"audio preserved, old ID3 stripped (audio_off %zu)", audio_after);
|
||||||
|
}
|
||||||
|
memset(&got, 0, sizeof got);
|
||||||
|
CHECK(ncm_tag_read_file(path, &got) == 0 && strcmp(got.title, "晴天 (Live)") == 0,
|
||||||
|
"read back after replacing old tag (got: %s)", got.title);
|
||||||
|
free(before); free(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_edges(void)
|
||||||
|
{
|
||||||
|
ncm_tag_meta_t m, empty;
|
||||||
|
char path[256];
|
||||||
|
unsigned char *before, *after;
|
||||||
|
size_t blen, alen;
|
||||||
|
long before_sz;
|
||||||
|
|
||||||
|
printf("== 边界 / 降级 ==\n");
|
||||||
|
/* 空元数据:不动文件 */
|
||||||
|
memset(&empty, 0, sizeof empty);
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/noop.flac");
|
||||||
|
flac_make(path, 0, 64, 1, 16);
|
||||||
|
before_sz = file_size(path);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "flac", &empty) == 0, "empty meta returns OK");
|
||||||
|
CHECK(file_size(path) == before_sz, "empty meta leaves file untouched");
|
||||||
|
|
||||||
|
/* 无 tag 的裸文件:读取返回 NOENT */
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
CHECK(ncm_tag_read_file(path, &m) == NCM_ERR_NOENT, "untagged file -> NOENT");
|
||||||
|
|
||||||
|
/* 扩展名是 flac 但 magic 不符:失败且原文件不变、无临时残体
|
||||||
|
* (容器以 magic 为准:声明 flac 却无 fLaC 魔数 = 残体/损坏,拒绝写 tag 走降级) */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/fake.flac");
|
||||||
|
file_put(path, "NOTFLAC-DATA", 12);
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
snprintf(m.title, sizeof m.title, "x");
|
||||||
|
CHECK(ncm_tag_embed_file(path, "flac", &m) != 0, "non-FLAC magic rejected");
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(blen == 12 && memcmp(before, "NOTFLAC-DATA", 12) == 0, "original file unchanged");
|
||||||
|
free(before);
|
||||||
|
{
|
||||||
|
struct stat st;
|
||||||
|
char tmp[256];
|
||||||
|
snprintf(tmp, sizeof tmp, "%s.tag.tmp", path);
|
||||||
|
CHECK(stat(tmp, &st) != 0, "no .tag.tmp left behind");
|
||||||
|
}
|
||||||
|
/* MP4/M4A("....ftyp")不是 mp3:ID3v2 前缀会破坏 MP4 容器 → 必须拒绝(降级裸流) */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/fake.m4a.mp3");
|
||||||
|
file_put(path, "\x00\x00\x00\x20""ftypisom\x00\x00\x00\x00", 16);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) != 0, "MP4 container rejected (no ID3 prefix)");
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(blen == 16 && memcmp(before + 4, "ftyp", 4) == 0, "MP4 file unchanged");
|
||||||
|
free(before);
|
||||||
|
/* 不存在的文件 */
|
||||||
|
CHECK(ncm_tag_embed_file(TMPDIR_PREFIX "/nope.mp3", "mp3", &m) != 0, "missing file -> error");
|
||||||
|
CHECK(ncm_tag_read_file(TMPDIR_PREFIX "/nope.mp3", &m) == NCM_ERR_NOENT,
|
||||||
|
"read missing -> NOENT");
|
||||||
|
|
||||||
|
/* 大文件(3MB)分块搬运:音频段逐字节一致(对应 NCM_TAG_CHUNK 多次循环) */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/big.mp3");
|
||||||
|
{
|
||||||
|
size_t n = 3u * 1024u * 1024u;
|
||||||
|
unsigned char *big = (unsigned char *)malloc(n);
|
||||||
|
CHECK(big != NULL, "alloc 3MB fixture");
|
||||||
|
if (!big) { printf("\n== RESULT: FAIL ==\n"); exit(1); }
|
||||||
|
for (size_t i = 0; i < n; i++) big[i] = (unsigned char)(i * 131u + (i >> 8));
|
||||||
|
file_put(path, big, n);
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
snprintf(m.title, sizeof m.title, "big");
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) == 0, "embed 3MB (chunked copy)");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
if (after) {
|
||||||
|
size_t tsize = ((size_t)(after[6] & 0x7f) << 21) | ((size_t)(after[7] & 0x7f) << 14) |
|
||||||
|
((size_t)(after[8] & 0x7f) << 7) | (size_t)(after[9] & 0x7f);
|
||||||
|
size_t audio_after = 10 + tsize;
|
||||||
|
CHECK(alen - audio_after == n && mem_diff(after + audio_after, big, n) < 0,
|
||||||
|
"3MB audio preserved across chunk boundaries");
|
||||||
|
} else {
|
||||||
|
CHECK(0, "read back 3MB file");
|
||||||
|
}
|
||||||
|
free(after);
|
||||||
|
free(big);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_container_and_probe(void)
|
||||||
|
{
|
||||||
|
ncm_tag_meta_t m, got;
|
||||||
|
ncm_audio_info_t ai;
|
||||||
|
char path[256];
|
||||||
|
unsigned char *before, *after;
|
||||||
|
size_t blen, alen, vc_off, vc_len, audio_off;
|
||||||
|
int nvc = 0, last_ok = 0;
|
||||||
|
|
||||||
|
printf("== 实际容器分支 + 音频头探测 ==\n");
|
||||||
|
memset(&m, 0, sizeof m);
|
||||||
|
snprintf(m.title, sizeof m.title, "Container");
|
||||||
|
snprintf(m.artist, sizeof m.artist, "A");
|
||||||
|
|
||||||
|
/* 同名档位/扩展名不可信(真机 song_<id>_hires.mp3 实证):内容 fLaC + ext mp3
|
||||||
|
* → 必须写 VORBIS_COMMENT(按容器),不能塞 ID3。 */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/misnamed.mp3");
|
||||||
|
flac_make(path, 0, 512, 3, 24);
|
||||||
|
before = file_slurp(path, &blen);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) == 0, "embed with ext=mp3 but flac content");
|
||||||
|
after = file_slurp(path, &alen);
|
||||||
|
CHECK(alen >= 4 && memcmp(after, "fLaC", 4) == 0, "container decides: still fLaC (no ID3)");
|
||||||
|
CHECK(flac_scan(after, alen, &vc_off, &vc_len, &audio_off, &nvc, &last_ok) == 0 &&
|
||||||
|
nvc == 1 && last_ok == 1, "VORBIS_COMMENT written by actual container (n=%d)", nvc);
|
||||||
|
CHECK(alen - audio_off == 512 &&
|
||||||
|
mem_diff(after + audio_off, before + blen - 512, 512) < 0, "audio preserved");
|
||||||
|
memset(&got, 0, sizeof got);
|
||||||
|
CHECK(ncm_tag_read_file(path, &got) == 0 && strcmp(got.title, "Container") == 0,
|
||||||
|
"read back by container (got: %s)", got.title);
|
||||||
|
free(before); free(after);
|
||||||
|
|
||||||
|
/* 探测:FLAC 16bit → SQ 依据;24bit → Hi-Res 依据 */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/p16.flac");
|
||||||
|
flac_make(path, 0, 64, 1, 16);
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
CHECK(ncm_audio_probe_file(path, &ai) == 0 && strcmp(ai.container, "flac") == 0 &&
|
||||||
|
ai.bits_per_sample == 16 && ai.sample_rate == 44100,
|
||||||
|
"probe flac 16bit: %s %dbit %dHz", ai.container, ai.bits_per_sample, ai.sample_rate);
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/p24.flac");
|
||||||
|
flac_make(path, 0, 64, 1, 24);
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
CHECK(ncm_audio_probe_file(path, &ai) == 0 && ai.bits_per_sample == 24,
|
||||||
|
"probe flac 24bit: %dbit %dHz", ai.bits_per_sample, ai.sample_rate);
|
||||||
|
|
||||||
|
/* 探测:MP3 原始帧 / 带 ID3 头(嵌入后)都应识别出 mp3 + 320kbps */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/p.mp3");
|
||||||
|
{
|
||||||
|
unsigned char frame[512];
|
||||||
|
for (size_t i = 0; i < sizeof frame; i++) frame[i] = (unsigned char)(0xF0 ^ (i * 7));
|
||||||
|
frame[0] = 0xFF; frame[1] = 0xFB; frame[2] = 0xE0; frame[3] = 0x00;
|
||||||
|
file_put(path, frame, sizeof frame);
|
||||||
|
}
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
CHECK(ncm_audio_probe_file(path, &ai) == 0 && strcmp(ai.container, "mp3") == 0 &&
|
||||||
|
ai.bitrate_kbps == 320 && ai.sample_rate == 44100,
|
||||||
|
"probe raw mp3: %d kbps %d Hz", ai.bitrate_kbps, ai.sample_rate);
|
||||||
|
CHECK(ncm_tag_embed_file(path, "mp3", &m) == 0, "tag raw mp3 for probe");
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
CHECK(ncm_audio_probe_file(path, &ai) == 0 && ai.bitrate_kbps == 320,
|
||||||
|
"probe tagged mp3 skips ID3v2 (%d kbps)", ai.bitrate_kbps);
|
||||||
|
|
||||||
|
/* 未知容器 */
|
||||||
|
snprintf(path, sizeof path, TMPDIR_PREFIX "/p.bin");
|
||||||
|
file_put(path, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b", 12);
|
||||||
|
memset(&ai, 0, sizeof ai);
|
||||||
|
CHECK(ncm_audio_probe_file(path, &ai) != 0, "garbage -> probe fails (unknown container)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
const char *base = getenv("TMPDIR"); /* Termux:/tmp 不可写,用 $TMPDIR */
|
||||||
|
printf("ncm_tag self-test\n");
|
||||||
|
if (base && base[0] && chdir(base) != 0) printf(" warn: chdir(%s) failed\n", base);
|
||||||
|
mkdir_tmp();
|
||||||
|
test_flac();
|
||||||
|
test_mp3();
|
||||||
|
test_edges();
|
||||||
|
test_container_and_probe();
|
||||||
|
printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS ==");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user