feat(ncm): 缓存文件内嵌元数据模块 ncm_tag(FLAC VORBIS_COMMENT / MP3 ID3v2.3 写读 + 音频头探测)

core/ncm_tag.c/.h(纯 libc,宿主可单测,不依赖 mbedtls/jsmn):
- FLAC:在元数据末块之后插入 VORBIS_COMMENT(TITLE/ARTIST/ALBUM),旧 VORBIS_COMMENT
  替换、PADDING/PICTURE 原样保留;MP3:ID3v2.3 TIT2/TPE1/TALB(UTF-16LE+BOM,中英文
  与 emoji 均可),源已带 ID3v2 时先剥旧标签(不留双标签)。
- **按实际容器分支**(读 fLaC 魔数):缓存名档位段是请求档,真机存在
  song_<id>_hires.mp3(无损请求被降级链落到 exhigh mp3),不能按名字判容器。
- 写入 = 同目录 <path>.tag.tmp + 64KB 分块搬运 + fsync + rename 原子替换;任一步失败
  即 unlink 临时件、原文件保持不变(调用方 commit 裸流降级),峰值内存 64KB。
- ncm_audio_probe_file:只读文件头探测实际容器/位深/码率(FLAC STREAMINFO / MP3 首帧),
  「已缓存列表」据此显示实际档位而非请求档。
- test/test_ncm_tag.c + build_ncm_tag_host.sh:FLAC/MP3 构建+读回、中文 roundtrip、
  旧标签替换、容器判定、3MB 分块、失败降级、探测 —— 51 断言全过,0 warning。
This commit is contained in:
AskaEth committed 2026-11-04 20:49:25 +08:00
1 parent e56c837f4f
commit 184ebd0ac9
7 files changed
+1276 -3

No files matched your search

+1 -1
View File
@@ -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
+3 -2
View File
@@ -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/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 \
+10
View File
@@ -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"
+1
View File
@@ -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
+729
View File
@@ -0,0 +1,729 @@
/* 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[4];
unsigned char *buf;
int in, out, rc, is_flac, n;
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; }
if (read_full(in, magic, sizeof magic) != (ssize_t)sizeof magic) { 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(真机降级产物同理)。 */
if (!is_flac && ext && strcasecmp(ext, "flac") == 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;
}
+70
View File
@@ -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 */
+462
View File
@@ -0,0 +1,462 @@
/* 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");
}
/* 不存在的文件 */
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;
}