From 184ebd0ac90613b4bcd202551297aa7f3e71e4cf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:34:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(ncm):=20=E7=BC=93=E5=AD=98=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=86=85=E5=B5=8C=E5=85=83=E6=95=B0=E6=8D=AE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20ncm=5Ftag=EF=BC=88FLAC=20VORBIS=5FCOMMENT=20/=20MP3?= =?UTF-8?q?=20ID3v2.3=20=E5=86=99=E8=AF=BB=20+=20=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E5=A4=B4=E6=8E=A2=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__hires.mp3(无损请求被降级链落到 exhigh mp3),不能按名字判容器。 - 写入 = 同目录 .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。 --- patch_v2/ncm_app/build_core.sh | 2 +- patch_v2/ncm_app/build_ncm.sh | 5 +- patch_v2/ncm_app/build_ncm_tag_host.sh | 10 + patch_v2/ncm_app/core/ncm.h | 1 + patch_v2/ncm_app/core/ncm_tag.c | 729 +++++++++++++++++++++++++ patch_v2/ncm_app/core/ncm_tag.h | 70 +++ patch_v2/ncm_app/test/test_ncm_tag.c | 462 ++++++++++++++++ 7 files changed, 1276 insertions(+), 3 deletions(-) create mode 100755 patch_v2/ncm_app/build_ncm_tag_host.sh create mode 100644 patch_v2/ncm_app/core/ncm_tag.c create mode 100644 patch_v2/ncm_app/core/ncm_tag.h create mode 100644 patch_v2/ncm_app/test/test_ncm_tag.c diff --git a/patch_v2/ncm_app/build_core.sh b/patch_v2/ncm_app/build_core.sh index ffe61e2..760cb60 100755 --- a/patch_v2/ncm_app/build_core.sh +++ b/patch_v2/ncm_app/build_core.sh @@ -7,7 +7,7 @@ INC_CORE="-Icore" 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/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 单独编译 JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index 3cb0127..4e99a8c 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -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/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/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/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 \ diff --git a/patch_v2/ncm_app/build_ncm_tag_host.sh b/patch_v2/ncm_app/build_ncm_tag_host.sh new file mode 100755 index 0000000..b27e39b --- /dev/null +++ b/patch_v2/ncm_app/build_ncm_tag_host.sh @@ -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" diff --git a/patch_v2/ncm_app/core/ncm.h b/patch_v2/ncm_app/core/ncm.h index e02260d..90f7043 100644 --- a/patch_v2/ncm_app/core/ncm.h +++ b/patch_v2/ncm_app/core/ncm.h @@ -15,6 +15,7 @@ #include "ncm_login.h" #include "ncm_api.h" #include "ncm_cache.h" +#include "ncm_tag.h" #include "ncm_apicache.h" #define NCM_CTX_CA_PATH_MAX 256 diff --git a/patch_v2/ncm_app/core/ncm_tag.c b/patch_v2/ncm_app/core/ncm_tag.c new file mode 100644 index 0000000..a439839 --- /dev/null +++ b/patch_v2/ncm_app/core/ncm_tag.c @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#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__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; +} diff --git a/patch_v2/ncm_app/core/ncm_tag.h b/patch_v2/ncm_app/core/ncm_tag.h new file mode 100644 index 0000000..92c6b95 --- /dev/null +++ b/patch_v2/ncm_app/core/ncm_tag.h @@ -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__<请求档位>.<实际格式>,档位段是 + * 请求档、不代表容器(真机存在 song_171650_hires.mp3:无损档请求被降级链落到 exhigh mp3); + * 故写 tag 只认 "fLaC" 魔数,其余按 MP3 处理,ext 仅作合法性提示。 + * 与既有的元数据分离体系(/meta/.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 +#include + +#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 */ diff --git a/patch_v2/ncm_app/test/test_ncm_tag.c b/patch_v2/ncm_app/test/test_ncm_tag.c new file mode 100644 index 0000000..81df582 --- /dev/null +++ b/patch_v2/ncm_app/test/test_ncm_tag.c @@ -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 +#include +#include +#include +#include + +#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__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; +}