From 184ebd0ac90613b4bcd202551297aa7f3e71e4cf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:34:01 +0800 Subject: [PATCH 1/4] =?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; +} From f392ee5c48b7aa9b9c7ee93440ee72ac44ed2be2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:34:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(ncm):=20=E9=A6=96=E9=A1=B5=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E3=80=8C=E5=B7=B2=E7=BC=93=E5=AD=98=E3=80=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=B7=B2=E7=BC=93=E5=AD=98=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E6=AD=8C=E5=90=8D/=E6=AD=8C=E6=89=8B/?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E6=A1=A3=E4=BD=8D/=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=EF=BC=8C=E7=82=B9=E6=92=AD/=E5=8D=95=E6=9B=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4/=E6=B8=85=E7=A9=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入口:首页 topbar 新增动作 3(MEOW_ICON_DOWNLOAD 位图,原「回播放页」顺延动作 4)→ push ACT_CACHE;左缘返回弹回首页(app.c 路由补齐 tap/swipe/touch/tick)。 列表页 app/ui/cache.c(对齐 queue.c 惯例:圆角卡/红 mini-vinyl/按压红条/当前播放行高亮): - 行 = 歌名 / 歌手 · 实际档位 · 大小;空态/页脚(N 首 · 合计 X MB)。 - 点行 = 点播(NCM_QUEUE_CACHE 队列,上/下一首在缓存列表内循环);行右「删除」=单曲删除; 顶栏「清空」=确认框 + ncm_cache_clear(与设置页「清理缓存」同语义)。 - 数据全在 worker:cache_reload → appstate_cache_list_load → ncm_cache_scan(读目录 + 文件内 tag + 音频头探测)→ 快照;UI 线程零磁盘 IO。快照上限 512 条(最近访问优先, ≈116KB bss),更多条目按 mtime 保留最近的。 core: - ncm_cache_scan / ncm_cache_remove(songs/ 池,song__<请求档>.<实际格式> 解析, 跳过 .tmp/.part 与非数字 id;按 mtime 降序;历史双点名删除兜底)。 - ncm_api_level_from_key(缓存键后缀 ↔ 档位枚举,与 ncm_api_level_key 互逆)。 - **档位显示实际档而非请求档**:真机存在 song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3),故 scan 用 ncm_audio_probe_file 读实际容器参数后只降不升地推断 (FLAC 16bit→无损 / 24bit→Hi-Res+,MP3 ≤128k→标准 / >128k→极高)。 播放:player_manager 对 NCM_QUEUE_CACHE 用条目真实键(id+请求档+实际扩展名)直读缓存 文件,不按用户当前档键重下;song_local_file/play_is_same_song/queue 页均支持第三种队列。 宿主 mock(NCM_PAGE=cache)造 3 条缓存 + 嵌入 tag + 同步扫描。 --- patch_v2/ncm_app/app/app.c | 10 +- patch_v2/ncm_app/app/logic/appstate.c | 101 +++++ patch_v2/ncm_app/app/ncm_app_internal.h | 34 ++ patch_v2/ncm_app/app/play/ipc_media.c | 1 + patch_v2/ncm_app/app/play/player_manager.c | 32 +- patch_v2/ncm_app/app/test/test_ncm_app.c | 38 +- patch_v2/ncm_app/app/ui/cache.c | 412 +++++++++++++++++++++ patch_v2/ncm_app/app/ui/home.c | 17 +- patch_v2/ncm_app/app/ui/player.c | 11 + patch_v2/ncm_app/app/ui/queue.c | 11 +- patch_v2/ncm_app/build_ncm.sh | 2 +- patch_v2/ncm_app/build_ncm_host.sh | 3 +- patch_v2/ncm_app/core/ncm_api.c | 16 + patch_v2/ncm_app/core/ncm_api.h | 2 + patch_v2/ncm_app/core/ncm_cache.c | 133 +++++++ patch_v2/ncm_app/core/ncm_cache.h | 24 ++ 16 files changed, 829 insertions(+), 18 deletions(-) create mode 100644 patch_v2/ncm_app/app/ui/cache.c diff --git a/patch_v2/ncm_app/app/app.c b/patch_v2/ncm_app/app/app.c index 573f81c..9c9cc47 100644 --- a/patch_v2/ncm_app/app/app.c +++ b/patch_v2/ncm_app/app/app.c @@ -24,7 +24,7 @@ volatile int g_net_ready = 0; int g_host_mock = 0; static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view, - *g_search_view, *g_queue_view; + *g_search_view, *g_queue_view, *g_cache_view; static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); } @@ -93,6 +93,7 @@ static void app_on_tap(int x, int y) if (settings_is_active()) { settings_on_tap(x, y); return; } if (playlist_is_active()) { playlist_on_tap(x, y); return; } if (queue_is_active()) { queue_on_tap(x, y); return; } + if (cache_is_active()) { cache_on_tap(x, y); return; } if (search_is_active()) { search_on_tap(x, y); return; } home_on_tap(x, y); } @@ -107,6 +108,7 @@ static void app_on_swipe(int dir, int dx, int dy) if (settings_is_active()) { meow_view_page_pop(); return; } if (playlist_is_active()) { meow_view_page_pop(); return; } if (queue_is_active()) { meow_view_page_pop(); return; } + if (cache_is_active()) { meow_view_page_pop(); return; } if (search_is_active()) { meow_view_page_pop(); return; } if (home_is_active()) { /* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。 @@ -124,6 +126,7 @@ static void app_on_swipe(int dir, int dx, int dy) if (home_is_active()) { home_swipe(dir, dy); return; } if (playlist_is_active()) { playlist_swipe(dir, dy); return; } if (queue_is_active()) { queue_swipe(dir, dy); return; } + if (cache_is_active()) { cache_swipe(dir, dy); return; } if (search_is_active()) { search_swipe(dir, dy); return; } if (player_is_active()) { player_on_swipe(dir, dy); return; } } @@ -134,6 +137,7 @@ static void app_on_touch_down(int x, int y) else if (home_is_active()) home_touch_down(x, y); else if (playlist_is_active()) playlist_touch_down(x, y); else if (queue_is_active()) queue_touch_down(x, y); + else if (cache_is_active()) cache_touch_down(x, y); else if (settings_is_active()) settings_touch_down(x, y); else if (search_is_active()) search_touch_down(x, y); } @@ -144,6 +148,7 @@ static void app_on_touch_move(int x, int y) else if (home_is_active()) home_touch_move(x, y); else if (playlist_is_active()) playlist_touch_move(x, y); else if (queue_is_active()) queue_touch_move(x, y); + else if (cache_is_active()) cache_touch_move(x, y); else if (settings_is_active()) settings_touch_move(x, y); else if (search_is_active()) search_touch_move(x, y); } @@ -194,6 +199,7 @@ static void app_on_tick(void) if (player_is_active()) { player_tick(now); return; } if (search_is_active()) { search_tick(now); return; } if (queue_is_active()) { queue_tick(now); return; } + if (cache_is_active()) { cache_tick(now); return; } if (home_is_active()) { home_tick(now); } if (playlist_is_active()) { playlist_tick(now); } chrome_tick(); @@ -233,6 +239,7 @@ int main(void) g_set_view = settings_view_create(); g_search_view = search_view_create(); g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */ + g_cache_view = cache_view_create(); /* M5 已缓存列表页 */ mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */ /* 进入根页:已登录→home,否则→login */ @@ -256,6 +263,7 @@ int main(void) meow_view_free(g_login_view); meow_view_free(g_search_view); meow_view_free(g_queue_view); + meow_view_free(g_cache_view); meow_ui_shutdown(); meow_log(MEOW_LOG_INFO, "exit"); return 0; diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index d263710..c0652b2 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -783,6 +783,7 @@ void appstate_apply_play_mode(int m) int appstate_queue_count(void) { int c; + if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_count(); pthread_mutex_lock(&g_search_mu); c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count; pthread_mutex_unlock(&g_search_mu); @@ -792,6 +793,7 @@ int appstate_queue_count(void) int appstate_queue_song(int idx, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; + if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_song(idx, out); if (g_st.queue_src == NCM_QUEUE_SEARCH) { pthread_mutex_lock(&g_search_mu); int ok = (idx >= 0 && idx < g_st.search_song_count); @@ -803,6 +805,105 @@ int appstate_queue_song(int idx, ncm_song_t *out) return appstate_song_fetch(idx, out); } +/* ============================================================ + * 已缓存列表(ACT_CACHE / NCM_QUEUE_CACHE;数据源 = songs/ 池 + 文件内 tag) + * 数组读写经 g_cache_mu;扫描走 worker(磁盘 IO 不进 UI 线程)。 + * ============================================================ */ +static pthread_mutex_t g_cache_mu = PTHREAD_MUTEX_INITIALIZER; + +int appstate_cache_list_load(void) +{ + /* 堆上临时数组(≈116KB):扫描期间不持锁,UI/播放线程读旧快照不被 IO 阻塞。 */ + ncm_cache_item_t *tmp = (ncm_cache_item_t *)malloc(sizeof(ncm_cache_item_t) * NCM_CACHE_LIST_MAX); + if (!tmp) return NCM_ERR_OOM; + int n = ncm_cache_scan(&g_st.ctx.cache, tmp, NCM_CACHE_LIST_MAX); + if (n < 0) { free(tmp); return n; } + /* 旧无 tag 缓存:回退旁路元数据(songmeta)补名称/歌手,保持列表可读。 */ + for (int i = 0; i < n; i++) { + ncm_song_t s; + if (tmp[i].name[0] && tmp[i].artist[0]) continue; + if (songmeta_load(g_st.cache_dir, tmp[i].id, &s) != NCM_OK) continue; + if (!tmp[i].name[0]) ncm_strlcpy(tmp[i].name, s.name, sizeof tmp[i].name); + if (!tmp[i].artist[0]) ncm_strlcpy(tmp[i].artist, s.art_name, sizeof tmp[i].artist); + } + pthread_mutex_lock(&g_cache_mu); + memcpy(g_st.cache_items, tmp, sizeof(ncm_cache_item_t) * (size_t)n); + g_st.cache_item_count = n; + pthread_mutex_unlock(&g_cache_mu); + free(tmp); + return n; +} + +int appstate_cache_count(void) +{ + int n; + pthread_mutex_lock(&g_cache_mu); + n = g_st.cache_item_count; + pthread_mutex_unlock(&g_cache_mu); + return n < 0 ? 0 : n; +} + +int appstate_cache_item(int i, ncm_cache_item_t *out) +{ + if (!out) return NCM_ERR_ARG; + pthread_mutex_lock(&g_cache_mu); + int ok = (i >= 0 && i < g_st.cache_item_count); + if (ok) *out = g_st.cache_items[i]; + pthread_mutex_unlock(&g_cache_mu); + return ok ? NCM_OK : NCM_ERR_ARG; +} + +int appstate_cache_song(int i, ncm_song_t *out) +{ + ncm_cache_item_t it; + ncm_song_t m; + if (!out) return NCM_ERR_ARG; + if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG; + memset(out, 0, sizeof *out); + ncm_strlcpy(out->id, it.id, sizeof out->id); + ncm_strlcpy(out->name, it.name[0] ? it.name : it.id, sizeof out->name); + ncm_strlcpy(out->art_name, it.artist, sizeof out->art_name); + /* 旁路元数据(≤600B 单次读)补时长/封面/fee;播放页与上/下一首逻辑照旧可用。 */ + if (songmeta_load(g_st.cache_dir, it.id, &m) == NCM_OK) { + if (!it.name[0] && m.name[0]) ncm_strlcpy(out->name, m.name, sizeof out->name); + if (!it.artist[0] && m.art_name[0]) ncm_strlcpy(out->art_name, m.art_name, sizeof out->art_name); + out->duration_ms = m.duration_ms; + out->fee = m.fee; + ncm_strlcpy(out->al_pic, m.al_pic, sizeof out->al_pic); + } + return NCM_OK; +} + +int appstate_cache_remove(int i) +{ + ncm_cache_item_t it; + int rc; + if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG; + /* 条目可能已被 LRU/清空删掉:NOENT 也算「已不在」,照样摘除列表项。 */ + rc = ncm_cache_remove(&g_st.ctx.cache, it.id, it.level, it.ext); + if (rc != NCM_OK && rc != NCM_ERR_NOENT) return rc; + pthread_mutex_lock(&g_cache_mu); + for (int k = 0; k < g_st.cache_item_count; k++) { + if (strcmp(g_st.cache_items[k].id, it.id) != 0 || + strcmp(g_st.cache_items[k].level, it.level) != 0 || + strcmp(g_st.cache_items[k].ext, it.ext) != 0) continue; + memmove(&g_st.cache_items[k], &g_st.cache_items[k + 1], + sizeof(ncm_cache_item_t) * (size_t)(g_st.cache_item_count - k - 1)); + g_st.cache_item_count--; + break; + } + pthread_mutex_unlock(&g_cache_mu); + return NCM_OK; +} + +void appstate_cache_clear(void) +{ + ncm_cache_clear(&g_st.ctx.cache); /* songs/ + covers/ 的 song_*(设置页同语义) */ + pthread_mutex_lock(&g_cache_mu); + g_st.cache_item_count = 0; + pthread_mutex_unlock(&g_cache_mu); +} + int appstate_search_count(int tab) { int c; diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 43e5bb2..c8fb8f2 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -31,6 +31,7 @@ #define ACT_SETTINGS 4 /* 设置页 */ #define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */ #define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */ +#define ACT_CACHE 7 /* 已缓存列表页(首页顶栏「已缓存」入口,NCM_CACHE_DESIGN §2.5) */ /* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)---- * 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。 @@ -115,6 +116,11 @@ static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; } /* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */ #define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */ #define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */ +#define NCM_QUEUE_CACHE 2 /* 已缓存列表队列(点播缓存页条目;上/下一首在缓存列表内循环) */ + +/* 已缓存列表条目上限(NCM_CACHE_LIST_MAX 条 × sizeof(ncm_cache_item_t)≈232B ≈ 116KB bss)。 + * 缓存池条目更多时只展示最近访问的 max 条(scan 按 mtime 降序保留最近的)。 */ +#define NCM_CACHE_LIST_MAX 512 /* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */ #define NCM_PL_SEARCH (-2) @@ -269,6 +275,10 @@ typedef struct ncm_app_state { /* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */ int queue_src; + + /* 已缓存列表(ACT_CACHE 页 + NCM_QUEUE_CACHE 队列;worker 扫描磁盘填充,UI 只读) */ + ncm_cache_item_t cache_items[NCM_CACHE_LIST_MAX]; + int cache_item_count; } ncm_app_state_t; extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */ @@ -353,6 +363,7 @@ void player_tick(uint64_t now); /* 播放进度/歌词滚动 */ void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */ void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */ void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */ +void player_play_cache_song(int i); /* 从已缓存列表第 i 首点播(切 NCM_QUEUE_CACHE 队列) */ void player_pause_toggle(int on); void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */ void player_seek(int64_t ms); @@ -390,6 +401,19 @@ void queue_touch_move(int x, int y); void queue_open(void); /* 进当前播放队列页 */ void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */ +/* cache(已缓存列表页,ACT_CACHE:首页顶栏「已缓存」钮进入,NCM_CACHE_DESIGN §2.5) + * 列表 = songs/ 池扫描(worker 读盘,UI 零 IO);行显示 歌名/歌手/实际档位/大小; + * 点行=点播(NCM_QUEUE_CACHE),行右「删除」=单曲删除,顶栏「清空」=清空缓存池。 */ +meow_view *cache_view_create(void); +int cache_is_active(void); +int cache_on_tap(int x, int y); +void cache_swipe(int dir, int dist); +void cache_touch_down(int x, int y); +void cache_touch_move(int x, int y); +void cache_open(void); /* 进已缓存列表页(push ACT_CACHE) */ +void cache_reload(void); /* 起 worker 重扫缓存池(非阻塞) */ +void cache_tick(uint64_t now); /* UI tick:检查扫描 worker 完成并刷新 */ + /* search */ meow_view *search_view_create(void); int search_is_active(void); @@ -470,6 +494,16 @@ int appstate_queue_count(void); /* 当前播放队列长度( int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */ void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */ +/* ---- 已缓存列表(NCM_CACHE_LIST_MAX;磁盘扫描/删除只在 worker 或 UI 单次小 IO)---- + * queue_src==NCM_QUEUE_CACHE 时,appstate_queue_count/song 走本数组(播放页上/下一首同源)。 + * 名称/歌手优先取文件内 tag(与外部播放器同源),空则回退旁路元数据 /meta/.bin。 */ +int appstate_cache_list_load(void); /* worker:ncm_cache_scan + 旁路补名;返回条数或负错误码 */ +int appstate_cache_count(void); +int appstate_cache_item(int i, ncm_cache_item_t *out); /* 0=ok */ +int appstate_cache_song(int i, ncm_song_t *out); /* 合成 ncm_song_t(时长/封面/fee 取旁路) */ +int appstate_cache_remove(int i); /* 删文件 + 摘除条目;0=成功 */ +void appstate_cache_clear(void); /* 清空缓存池(含封面池,同设置页语义)+ 清列表 */ + /* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */ int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */ int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */ diff --git a/patch_v2/ncm_app/app/play/ipc_media.c b/patch_v2/ncm_app/app/play/ipc_media.c index 63eddb7..82e6c59 100644 --- a/patch_v2/ncm_app/app/play/ipc_media.c +++ b/patch_v2/ncm_app/app/play/ipc_media.c @@ -242,6 +242,7 @@ static const char *ncm_act_name(int act) case ACT_PLAYER: return "player"; case ACT_SETTINGS: return "settings"; case ACT_SEARCH: return "search"; + case ACT_CACHE: return "cache"; /* M5 已缓存列表(app.json 未声明常亮 → 照常息屏) */ default: return NULL; } } diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 671928e..9a13716 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -167,9 +167,10 @@ void player_manager_set_quality(int level, int cache_gb) static int play_is_same_song(int song_idx) { ncm_song_t cand; - int have = (appstate_get_queue() == NCM_QUEUE_SEARCH) - ? (appstate_search_song(song_idx, &cand) == NCM_OK) - : (appstate_song_copy(song_idx, &cand) == NCM_OK); + int src = appstate_get_queue(); + int have = (src == NCM_QUEUE_SEARCH) ? (appstate_search_song(song_idx, &cand) == NCM_OK) + : (src == NCM_QUEUE_CACHE) ? (appstate_cache_song(song_idx, &cand) == NCM_OK) + : (appstate_song_copy(song_idx, &cand) == NCM_OK); if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */ pthread_mutex_lock(&g_play.mu); int same = g_play.play_song_valid && g_play.play_song.id[0] && @@ -645,6 +646,31 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss char key[64]; audio_cache_key(key, sizeof key, id, g_level); + /* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名请求档)与实际扩展名直接 + * 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」 + * 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */ + if (appstate_get_queue() == NCM_QUEUE_CACHE) { + ncm_cache_item_t it; + if (appstate_cache_item(idx, &it) == NCM_OK) { + char ck[48]; + if (it.level[0]) snprintf(ck, sizeof ck, "%s_%s", it.id, it.level); + else ncm_strlcpy(ck, it.id, sizeof ck); + if (ncm_cache_lookup(&g_st.ctx.cache, ck, it.ext, path, psz) == NCM_OK) { + struct stat st; + if (stat(path, &st) == 0 && + stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) == 0) { + ss->total = (int64_t)st.st_size; + *bytes = ss->total; + pthread_mutex_lock(&g_play.mu); + g_play.eff_level = it.actual_level; /* 实际档(非请求档),UI 标注用 */ + pthread_mutex_unlock(&g_play.mu); + return NCM_OK; + } + } + } + return NCM_ERR_NOENT; + } + /* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */ int hit = 0; if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK || diff --git a/patch_v2/ncm_app/app/test/test_ncm_app.c b/patch_v2/ncm_app/app/test/test_ncm_app.c index e031076..9332752 100644 --- a/patch_v2/ncm_app/app/test/test_ncm_app.c +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -72,6 +72,37 @@ static void mock_lyric(void) lyric_parse(lrc, tl); } +/* M5:已缓存列表页 mock —— 造 3 条 songs/ 缓存(mp3 帧 + 嵌入 ID3v2.3),再同步扫描进快照。 */ +static void mock_cache(void) +{ + static const char *names[] = { "Song Cached One", "Song Cached Two", "Song Cached Three" }; + static const char *arts[] = { "Artist X", "Artist Y", "Artist Z" }; + static const char *lvks[] = { "exhigh", "hires", "lossless" }; + ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir); + for (int i = 0; i < 3; i++) { + char id[24], key[48]; + unsigned char fr[4096]; + ncm_cache_writer_t *w; + snprintf(id, sizeof id, "%ld", 5000L + i); + snprintf(key, sizeof key, "%s_%s", id, lvks[i]); + for (size_t k = 0; k < sizeof fr; k++) fr[k] = (unsigned char)(0x20 + (k * 7)); + fr[0] = 0xFF; fr[1] = 0xFB; fr[2] = 0xE0; fr[3] = 0x00; /* 320kbps / 44.1kHz */ + if (ncm_cache_open(&g_st.ctx.cache, key, "mp3", &w) != NCM_OK) continue; + ncm_cache_write(w, fr, sizeof fr); + if (ncm_cache_commit(w) != NCM_OK) continue; + char path[NCM_CACHE_DIR_MAX + 64]; + if (ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, sizeof path) == NCM_OK) { + ncm_tag_meta_t t; + memset(&t, 0, sizeof t); + snprintf(t.title, sizeof t.title, "%s", names[i]); + snprintf(t.artist, sizeof t.artist, "%s", arts[i]); + snprintf(t.album, sizeof t.album, "Album %d", i + 1); + ncm_tag_embed_file(path, "mp3", &t); + } + } + appstate_cache_list_load(); +} + static void mock_qr(void) { g_st.qr_size = 29; @@ -137,6 +168,7 @@ static int page_id(const char *name) if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */ if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */ if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */ + if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) return ACT_CACHE; /* M5 */ return -1; } @@ -268,8 +300,7 @@ int main(int argc, char **argv) appstate_search_history_add("Jay \u5553\u8868"); appstate_search_history_add("lofi beat"); appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */ - } else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { - /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */ + } else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */ mock_playlists(); mock_songs(); pthread_mutex_lock(&g_play.mu); g_play.state = NCM_PC_PLAYING; @@ -290,6 +321,8 @@ int main(int argc, char **argv) g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */ g_play.play_song_valid = 1; pthread_mutex_unlock(&g_play.mu); + } else if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) { + mock_cache(); /* M5:假缓存池 + 文件内 tag + 快照扫描 */ } else { mock_playlists(); } @@ -301,6 +334,7 @@ int main(int argc, char **argv) strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create(); else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */ else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */ + else if (page_id(name) == ACT_CACHE) v = cache_view_create(); /* M5 已缓存列表页 */ else if (strcmp(name, "settings") == 0) v = settings_view_create(); else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 || strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create(); diff --git a/patch_v2/ncm_app/app/ui/cache.c b/patch_v2/ncm_app/app/ui/cache.c new file mode 100644 index 0000000..fb791f8 --- /dev/null +++ b/patch_v2/ncm_app/app/ui/cache.c @@ -0,0 +1,412 @@ +/* ui/cache.c — 已缓存列表页(ACT_CACHE,首页顶栏「已缓存」钮进入)。 + * + * 数据源:songs/ 音频池(键=歌曲ID+请求档位,NCM_CACHE_DESIGN §2.5)。扫描在 worker: + * ncm_cache_scan(读目录 + 文件头 tag + 音频参数探测)→ appstate 快照; + * UI 线程只读快照(零磁盘 IO),列表由 meow_listview 虚拟化。 + * 行:歌名 / 歌手 · **实际档位** · 大小。**档位显示实际档而非请求档**:缓存名里的档位段只是 + * 请求档(真机存在 song__hires.mp3 = 无损请求被降级链落到 exhigh mp3),实际档由 + * 文件头(FLAC bps/采样率、MP3 首帧码率)推断。 + * 交互:点行=点播(NCM_QUEUE_CACHE,上/下一首在缓存列表内循环);行右「删除」=单曲删除; + * 顶栏「清空」=清空缓存池(确认框,与设置页「清理缓存」同语义)。 + * 主题/控件惯例同 queue.c:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮。 + * 左缘返回=弹回首页(app.c)。 + */ +#include "../ncm_app_internal.h" +#include +#include +#include +#include + +typedef struct { meow_view *view; int active; } cache_ctrl; +static cache_ctrl g_cc; + +static meow_widget *g_tb, *g_list; +static ncm_rect_t r_cont, r_list; +static int g_bg_id, g_draw_id, g_empty_id, g_footer_id; +static int g_press_row, g_press_delta, g_dragging, g_drag_last_y; +static int g_last_count; + +/* 扫描 worker(磁盘 IO 不进 UI 线程;gen 计数做完成信号) */ +static pthread_mutex_t g_cmu = PTHREAD_MUTEX_INITIALIZER; +static pthread_t g_cthr; +static int g_cthr_on; +static volatile int g_cgen, g_cacked; +static volatile int g_cloading; +static volatile int g_cerr; + +static void c_drag_reset(void) +{ + g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0; +} + +/* 字节数 → "12.3 MB"(整数运算,不引浮点 printf;MIPS 软浮点省体积)。 */ +static void fmt_size(uint64_t n, char *out, size_t sz) +{ + if (n >= (1024ULL * 1024ULL * 1024ULL)) { + uint64_t t = (n * 10ULL + 512ULL * 1024ULL * 1024ULL) / (1024ULL * 1024ULL * 1024ULL); + snprintf(out, sz, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else if (n >= 1024ULL * 1024ULL) { + uint64_t t = (n * 10ULL + 512ULL * 1024ULL) / (1024ULL * 1024ULL); + snprintf(out, sz, "%llu.%llu MB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else { + snprintf(out, sz, "%llu KB", (unsigned long long)(n / 1024ULL)); + } +} + +static const char *level_label(int lv) +{ + return (lv >= 0) ? ncm_level_name(lv) : "未知档"; +} + +/* 列表数据源(逐行快照读取,零 IO)。 */ +static int c_src2(int idx, meow_list_item *out, void *ctx) +{ + (void)ctx; + static ncm_cache_item_t it; + static char sub[192]; + static char title[100]; + char sz[24]; + + if (appstate_cache_item(idx, &it) != NCM_OK) return 0; + fmt_size(it.size, sz, sizeof sz); + snprintf(sub, sizeof sub, "%s · %s · %s", + it.artist[0] ? it.artist : "-", level_label(it.actual_level), sz); + snprintf(title, sizeof title, "%s", it.name[0] ? it.name : "(未知歌曲)"); + out->icon_kind = MEOW_LIST_ICON_NONE; /* 图标槽由红 overlays 画迷你黑胶 */ + out->title = title; + out->subtitle = sub; + out->right = MEOW_LIST_RIGHT_NONE; /* 右侧「删除」由 z=60 叠层自绘 */ + out->on = 0; out->enabled = 1; + return 1; +} + +/* 行背景(z=5,画在 listview 文本之下):圆角卡 + 当前播放行红色高亮。 */ +static void c_row_bg(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + int cnt = appstate_cache_count(); + if (cnt <= 0) return; + const meow_theme *th = meow_theme_get(); + const ncm_pal_t *pa = ncm_palette(g_dark); + int item = APP_ROW_H; + int off = meow_listview_scroll(g_list); + int first = off / item, last = (off + r_list.h) / item + 1; + if (last > cnt) last = cnt; + int cur = (appstate_get_queue() == NCM_QUEUE_CACHE) ? player_manager_playing_song() : -1; + for (int i = first; i < last; i++) { + int ry = ncm_row_card_y(item, i, off, r_list.y); + int th2 = ncm_row_card_h(item); + if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; /* 只画完整落在视口内的行 */ + if (i == cur) { + ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, pa->red_ctr); + meow_ui_fill_rect(r_list.x + 2, ry + 4, 4, th2 - 8, pa->red); + } else { + ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, th->surfaceContainer); + } + } +} + +/* 主绘制(z=60):迷你黑胶缩略 + 行右「删除」+ 按压红条;文本由 listview z=10 画。 */ +static void c_draw(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + const meow_theme *th = meow_theme_get(); + const ncm_pal_t *pa = ncm_palette(g_dark); + int cnt = appstate_cache_count(); + if (cnt <= 0) return; + int item = APP_ROW_H, isz = 24; + int off = meow_listview_scroll(g_list); + int first = off / item, last = (off + r_list.h) / item + 1; + if (last > cnt) last = cnt; + for (int i = first; i < last; i++) { + int ry = ncm_row_card_y(item, i, off, r_list.y); + int th2 = ncm_row_card_h(item); + if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; + int icy = ry + (th2 - isz) / 2, bx = r_list.x + 16; + ncm_fill_round(bx, icy, isz, isz, 6, pa->red); + meow_ui_fill_rect(bx + isz / 2 - 2, icy + isz / 2 - 2, 4, 4, pa->on_red); /* 盘芯 */ + /* 行右「删除」(触达区 = 右 48px,见 cache_on_tap) */ + meow_ui_text_right(r_list.x + r_list.w - APP_PAD, + ry + (th2 - 18) / 2, "删除", 18, pa->red); + if (g_press_row == i) { + meow_ui_fill_rect(r_list.x, ry, 4, th2, pa->red); + meow_ui_fill_rect_alpha(r_list.x, ry, r_list.w, th2, pa->red, 26); + } + /* 擦除 z=10 的 SDK 行分隔线(现落进卡片间空隙):以页底色填空隙带;不越出列表视口底。 */ + int gy = ry + th2, gh = NCM_ROW_GAP; + if (gy + gh > r_list.y + r_list.h) gh = r_list.y + r_list.h - gy; + if (gh > 0) meow_ui_fill_rect(r_list.x, gy, r_list.w, gh, th->bg); + } +} + +static void c_draw_empty(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + if (appstate_cache_count() != 0) return; + if (g_cloading) { ncm_draw_empty(r_list, "LOADING", "正在扫描缓存..."); return; } + if (g_cerr) { ncm_draw_empty(r_list, "ERROR", "缓存目录读取失败"); return; } + ncm_draw_empty(r_list, "暂无缓存", "播放过的歌曲会自动缓存"); +} + +static void c_draw_footer(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_CACHE) return; + int n = appstate_cache_count(); + if (n <= 0) return; + uint64_t total = 0; + ncm_cache_item_t it; + for (int i = 0; i < n; i++) + if (appstate_cache_item(i, &it) == NCM_OK) total += it.size; + char sz[24], foot[96]; + fmt_size(total, sz, sizeof sz); + snprintf(foot, sizeof foot, "%d 首 · 合计 %s", n, sz); + ncm_draw_footer(r_cont, foot); +} + +static void c_apply(void) +{ + if (!g_list) return; + int n = appstate_cache_count(); + meow_listview_set_count(g_list, n); + if (n != g_last_count) { + g_last_count = n; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); +} + +static void *cache_worker(void *arg) +{ + (void)arg; + int rc = appstate_cache_list_load(); /* 扫描 + 文件内 tag + 旁路补名:全部磁盘 IO */ + pthread_mutex_lock(&g_cmu); + g_cerr = (rc < 0) ? rc : 0; + g_cloading = 0; + g_cgen++; + pthread_mutex_unlock(&g_cmu); + return NULL; +} + +void cache_reload(void) +{ + if (g_host_mock) { /* 宿主渲染:同步扫描(无线程/无延时,截图确定) */ + int rc = appstate_cache_list_load(); + g_cerr = (rc < 0) ? rc : 0; + g_cloading = 0; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + return; + } + if (g_cthr_on) return; + g_cloading = 1; + g_cthr_on = 1; + if (ncm_thread_create(&g_cthr, cache_worker, NULL) != 0) { + g_cthr_on = 0; g_cloading = 0; + g_cerr = NCM_ERR_IO; + } + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +/* 单曲删除(行右「删除」):删文件 + 摘除快照项 + 提示。 */ +static void cache_delete_row(int row) +{ + ncm_cache_item_t it; + if (appstate_cache_item(row, &it) != NCM_OK) return; + char name[96], msg[160]; + ncm_strlcpy(name, it.name[0] ? it.name : it.id, sizeof name); + if (appstate_cache_remove(row) == 0) { + snprintf(msg, sizeof msg, "已删除:%s", name); + ncm_snack(msg); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } else { + ncm_snack("删除失败"); + } +} + +/* 清空(顶栏「清空」):确认框(设置页「清理缓存」同语义:删除全部音频 + 封面缓存条目)。 */ +static void cache_confirm_clear(void) +{ + int n = appstate_cache_count(); + if (n <= 0) { ncm_snack("缓存已为空"); return; } + uint64_t total = 0; + ncm_cache_item_t it; + for (int i = 0; i < n; i++) + if (appstate_cache_item(i, &it) == NCM_OK) total += it.size; + char sz[24], msg[192]; + fmt_size(total, sz, sizeof sz); + snprintf(msg, sizeof msg, "将删除全部 %d 首已缓存歌曲 (%s) 及封面缓存。\n已播放的歌曲需重新下载。", + n, sz); + meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "清空缓存?"); + const char *msgs[1]; + msgs[0] = msg; + meow_dialog_set_items(d, msgs, 1, -1); + int yes = meow_dialog_confirm(d); + meow_dialog_free(d); + if (!yes) return; + appstate_cache_clear(); + ncm_snack("缓存已清理"); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +void cache_open(void) +{ + meow_view_page_push(ACT_CACHE); + cache_reload(); + c_apply(); + meow_ui_paint_full(); +} + +int cache_is_active(void) { return g_cc.active; } + +int cache_on_tap(int x, int y) +{ + if (g_tb) { + int ah = meow_topappbar_hit(g_tb, x, y); + if (ah == MEOW_TOPBAR_BACK) { c_drag_reset(); meow_view_page_pop(); return 1; } + if (ah == 0) { c_drag_reset(); cache_confirm_clear(); return 1; } /* 文本动作「清空」 */ + } + if (meow_widget_hit(g_list, x, y)) { + int was_drag = (g_press_delta > 10); + int row = (g_press_row >= 0) ? g_press_row : meow_listview_hit(g_list, x, y); + int del = (x >= r_list.x + r_list.w - APP_OP_W); + c_drag_reset(); + if (row < 0 || was_drag) return 1; + if (del) { cache_delete_row(row); return 1; } + player_play_cache_song(row); + return 1; + } + return 0; +} + +void cache_touch_down(int x, int y) +{ + c_drag_reset(); + if (appstate_cache_count() <= 0) return; + if (meow_widget_hit(g_list, x, y)) { + int row = meow_listview_hit(g_list, x, y); + if (row >= 0) g_press_row = row; + g_dragging = 1; g_drag_last_y = y; + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); + } +} + +void cache_touch_move(int x, int y) +{ + (void)x; + if (!g_dragging) return; + if (g_press_row >= 0) { + int d = y - g_drag_last_y; if (d < 0) d = -d; + g_press_delta += d; + if (g_press_delta > 10) g_press_row = -1; + } + int dy = y - g_drag_last_y; g_drag_last_y = y; + if (dy == 0) return; + int before = meow_listview_scroll(g_list); + meow_listview_scroll_by(g_list, -dy); + if (meow_listview_scroll(g_list) != before) + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +void cache_swipe(int dir, int dist) +{ + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (appstate_cache_count() <= 0) return; + int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; + int before = meow_listview_scroll(g_list); + meow_listview_scroll_by(g_list, del); + if (meow_listview_scroll(g_list) != before) + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +void cache_tick(uint64_t now) +{ + (void)now; + chrome_tick(); + if (!g_cc.active) return; + if (!g_cthr_on) return; + int gen, acked; + pthread_mutex_lock(&g_cmu); + gen = g_cgen; acked = g_cacked; + pthread_mutex_unlock(&g_cmu); + if (gen == acked) return; + pthread_mutex_lock(&g_cmu); + g_cacked = gen; + pthread_mutex_unlock(&g_cmu); + g_cthr_on = 0; + if (g_cerr) ncm_snack("缓存读取失败"); + c_apply(); + meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h); +} + +/* 生命周期 */ +static void c_page_create(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->view = v; + r_cont = ncm_content_rect(); + r_list = NR(16, r_cont.y + 8, r_cont.w - 32, + r_cont.y + r_cont.h - APP_FOOTER_H - (r_cont.y + 8)); + if (r_list.h < 0) r_list.h = 0; + meow_ui_activity_push(ACT_CACHE); + g_tb = ncm_topbar_create("已缓存", 1, "清空"); /* 文本动作(动作0)=清空缓存 */ + g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, + c_src2, NULL, MEOW_LIST_SEL_NONE); + meow_listview_set_item_h(g_list, APP_ROW_H); + meow_ui_activity_pop(); + meow_widget_set_z(g_list, 10); + g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h, + c_row_bg, NULL); + g_draw_id = meow_draw_custom_register(60, r_cont.x, r_cont.y, r_cont.w, r_cont.h, + c_draw, NULL); + g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h, + c_draw_empty, NULL); + g_footer_id = meow_draw_custom_register(1, r_cont.x, r_cont.y + r_cont.h - APP_FOOTER_H, + r_cont.w, APP_FOOTER_H, c_draw_footer, NULL); + c_drag_reset(); +} + +static void c_page_enter(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->active = 1; + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->bg); + g_last_count = -1; + cache_reload(); /* 每次进入重扫(播放/删除后 LRU 变化) */ + c_apply(); + meow_ui_paint_full(); +} + +static void c_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((cache_ctrl *)ctx)->active = 0; +} + +static void c_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + if (g_bg_id) { meow_draw_unregister(g_bg_id); g_bg_id = 0; } + if (g_draw_id) { meow_draw_unregister(g_draw_id); g_draw_id = 0; } + if (g_empty_id) { meow_draw_unregister(g_empty_id); g_empty_id = 0; } + if (g_footer_id) { meow_draw_unregister(g_footer_id); g_footer_id = 0; } + if (g_list) { meow_widget_free(g_list); g_list = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } +} + +meow_view *cache_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = c_page_create, + .on_enter = c_page_enter, + .on_leave = c_page_leave, + .on_destroy = c_page_destroy, + }; + return meow_view_create(ACT_CACHE, MEOW_VIEW_ACTIVITY, &h, &g_cc); +} diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index 1fca710..cd6947d 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -1,9 +1,10 @@ /* ui/home.c — 我的音乐页(ACT_HOME,根页,已登录)。 * - * 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+搜索/刷新/设置/回播放页动作)+ + * 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+刷新/设置/搜索/已缓存/回播放页动作)+ * 个人区卡片(头像/昵称/ID)+ 双页签「我创建的/我收藏的」(meow_tabbar + 各自 meow_listview)+ * 组头红条 + 歌单行(rounded card + 红 mini-vinyl 缩略 + 红色按压竖条)。 - * 批6:顶栏「回播放页」按钮(sb_icons 位图,动作3 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。 + * 批6:顶栏「回播放页」按钮(sb_icons 位图,动作4 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。 + * M5:顶栏「已缓存」按钮(动作3,MEOW_ICON_DOWNLOAD 位图)→ 已缓存列表页 ACT_CACHE。 * * 品牌红走 ncm_palette(g_dark)(App 无权改 SDK MD3 蓝 primary),代码零硬编码散落: * 中性色读 meow_theme_get(),红读 ncm_palette()。SDK listview 的 MD3 蓝选中不可改, @@ -299,7 +300,8 @@ int home_on_tap(int x, int y) } if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */ if (ah == 2) { home_drag_reset(); search_open(); return 1; } /* 搜索 */ - if (ah == 3) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */ + if (ah == 3) { home_drag_reset(); cache_open(); return 1; } /* 已缓存列表 */ + if (ah == 4) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */ } if (g_tabs && meow_widget_hit(g_tabs, x, y)) { int t = meow_tabbar_hit(g_tabs, x, y); @@ -376,15 +378,18 @@ static void home_page_create(meow_view *v, void *ctx) g_tb = ncm_topbar_create("我的音乐", 0, "\xe2\x86\xbb"); /* 刷新(动作0) */ meow_topappbar_add_action(g_tb, "\xe2\x9a\x99"); /* 设置(动作1) */ meow_topappbar_add_action(g_tb, "\xe2\x8c\x95"); /* 搜索(动作2) */ - /* 批6:回播放页(动作3, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate + /* M5:已缓存列表入口(动作3)——songs/ 池全部已缓存歌曲(点播/删除/清空)。 */ + meow_topappbar_add_action(g_tb, "\xe2\x87\xa9"); /* 已缓存(动作3) */ + /* 批6:回播放页(动作4, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate * 现有语义展示空态(暂无播放)/上次会话状态。 */ - meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作3, 最右) */ + meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作4, 最右) */ /* §307 topbar 图标迁移:三位码点(↻ ⚙ ⌕)在 4k 字库缺字形渲染成豆腐(WORK_STATE 310 前置 * 症状①),改为 sb_icons 位图(Material Symbols Rounded,字符标签仅作占位,绘制走位图)。 */ meow_topappbar_set_item_icon(g_tb, 0, MEOW_ICON_REFRESH); meow_topappbar_set_item_icon(g_tb, 1, MEOW_ICON_SETTINGS); meow_topappbar_set_item_icon(g_tb, 2, MEOW_ICON_SEARCH); - meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */ + meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_DOWNLOAD); /* 已缓存列表 */ + meow_topappbar_set_item_icon(g_tb, 4, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */ g_tabs = meow_tabbar_create(r_tabs.x, r_tabs.y, r_tabs.w, r_tabs.h); meow_tabbar_add_tab(g_tabs, tab_label(0)); meow_tabbar_add_tab(g_tabs, tab_label(1)); diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c index d513235..c1686ec 100644 --- a/patch_v2/ncm_app/app/ui/player.c +++ b/patch_v2/ncm_app/app/ui/player.c @@ -783,6 +783,8 @@ void player_play_queue_song(int idx) int have = 0; if (appstate_get_queue() == NCM_QUEUE_SEARCH) { if (appstate_search_song(idx, &s) == 0) have = 1; + } else if (appstate_get_queue() == NCM_QUEUE_CACHE) { + if (appstate_cache_song(idx, &s) == 0) have = 1; } else if (appstate_song_copy(idx, &s) == 0) { have = 1; } @@ -794,6 +796,15 @@ void player_play_queue_song(int idx) meow_view_page_push(ACT_PLAYER); } +/* 已缓存列表点播(ACT_CACHE 页点行):切 NCM_QUEUE_CACHE 队列后走同一套「队列点播」路径—— + * 上/下一首在缓存列表内循环;播放线程按条目真实键直读缓存文件(不重下)。 */ +void player_play_cache_song(int i) +{ + if (i < 0 || i >= appstate_cache_count()) return; + appstate_set_queue(NCM_QUEUE_CACHE); + player_play_queue_song(i); +} + void player_pause_toggle(int on) { if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0); diff --git a/patch_v2/ncm_app/app/ui/queue.c b/patch_v2/ncm_app/app/ui/queue.c index ce56af2..199c3f8 100644 --- a/patch_v2/ncm_app/app/ui/queue.c +++ b/patch_v2/ncm_app/app/ui/queue.c @@ -1,7 +1,7 @@ /* ui/queue.c — 当前实时播放队列页(ACT_QUEUE,§NEW:播放页「队列」钮进入)。 * - * 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 或 搜索单曲结果集), - * 含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17): + * 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 / 搜索单曲结果集 / + * 已缓存列表),含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17): * - count = appstate_queue_count()(全量:track_ids_count 或 search_song_count,可 5194 首); * - 行内容 = src2 逐行按 queue_src 取(PLAYLIST→滑窗拷贝,SEARCH→搜索数组),未物化槽显示 * ".../加载中" 占位,视口 ±24 行未物化 → 后台 worker ensure_range 补载(盘缓存优先,批≤NCM_SONG_BATCH); @@ -47,8 +47,11 @@ static void q_drag_reset(void) * PLAYLIST:滑窗拷贝(UI 线程不触发网络);SEARCH:搜索数组(内存)。 */ static int q_song_get(int idx, ncm_song_t *out) { - if (appstate_get_queue() == NCM_QUEUE_SEARCH) + int src = appstate_get_queue(); + if (src == NCM_QUEUE_SEARCH) return (appstate_search_song(idx, out) == 0) ? 0 : -1; + if (src == NCM_QUEUE_CACHE) + return (appstate_cache_song(idx, out) == 0) ? 0 : -1; return (appstate_song_copy(idx, out) == 0) ? 0 : -1; } @@ -195,7 +198,7 @@ static void *queue_worker(void *arg) * SEARCH 队列全在内存,无需补载。 */ static void q_maybe_ensure(void) { - if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; + if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; /* SEARCH/CACHE 队列全在内存 */ if (g_qthr_on || g_host_mock) return; int total = appstate_queue_count(); if (total <= 0) return; diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index 4e99a8c..189ae09 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -18,7 +18,7 @@ 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_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/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \ app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \ diff --git a/patch_v2/ncm_app/build_ncm_host.sh b/patch_v2/ncm_app/build_ncm_host.sh index 1fbd1eb..95f57c4 100755 --- a/patch_v2/ncm_app/build_ncm_host.sh +++ b/patch_v2/ncm_app/build_ncm_host.sh @@ -30,7 +30,8 @@ $CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_ho echo "==> host app sources (exclude app.c: has main + g_st; test provides them)" APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ - app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \ + app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \ + app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ app/logic/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/core/ncm_api.c b/patch_v2/ncm_app/core/ncm_api.c index e9d064f..e8be2fb 100644 --- a/patch_v2/ncm_app/core/ncm_api.c +++ b/patch_v2/ncm_app/core/ncm_api.c @@ -39,6 +39,22 @@ const char *ncm_api_level_key(int level) { return ncm_api_level_str(level); } +/* 键后缀 → 档位枚举(缓存扫描/「已缓存列表」用;与 ncm_api_level_key 严格互逆)。 */ +int ncm_api_level_from_key(const char *key) { + if (!key || !key[0]) return -1; + if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND; + if (!strcmp(key, "sky")) return NCM_LEVEL_SKY; + if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER; + if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT; + if (!strcmp(key, "dolby")) return NCM_LEVEL_DOLBY; + if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES; + if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS; + if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH; + if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER; + if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD; + return -1; +} + int ncm_api_level_gate(int level) { switch (level) { case NCM_LEVEL_LOSSLESS: diff --git a/patch_v2/ncm_app/core/ncm_api.h b/patch_v2/ncm_app/core/ncm_api.h index 1978989..69bcd21 100644 --- a/patch_v2/ncm_app/core/ncm_api.h +++ b/patch_v2/ncm_app/core/ncm_api.h @@ -39,6 +39,8 @@ enum { }; const char *ncm_api_level_str(int level); /* API level 串(SKY/SURROUND 同为 "sky") */ const char *ncm_api_level_key(int level); /* 缓存键唯一后缀(SKY="sky"/SURROUND="sky_c51") */ +/* 缓存键后缀 → 档位枚举(ncm_api_level_key 的逆;未知/空返回 -1)。 */ +int ncm_api_level_from_key(const char *key); /* 会员等级(vip/info 判定,BIZ_NOTES §1):用户语义 0=非会员 / 1=畅听会员(VIP) / * 2=黑胶会员(CVIP) / 3=超级会员(SVIP)。枚举名保留旧名(CVIP/SVIP)避免大改,新增 diff --git a/patch_v2/ncm_app/core/ncm_cache.c b/patch_v2/ncm_app/core/ncm_cache.c index f105707..806fdb9 100644 --- a/patch_v2/ncm_app/core/ncm_cache.c +++ b/patch_v2/ncm_app/core/ncm_cache.c @@ -2,6 +2,8 @@ #include "ncm_cache.h" #include "ncm_common.h" #include "ncm_store.h" +#include "ncm_tag.h" +#include "ncm_api.h" #include #include @@ -461,3 +463,134 @@ int ncm_cache_clear(const ncm_cache_t *c) { } return NCM_OK; } + +/* ---- 「已缓存列表」:扫描/删除(键=歌曲ID+档位,NCM_CACHE_DESIGN §2.5)---- + * 文件名 song__<请求档位>.<实际格式>;**档位段只是请求档**,实际档由文件头探测得出 + * (真机 song__hires.mp3 = 无损请求被降级链落到 exhigh mp3)。 */ + +/* 解析条目名 → id/请求档位键/实际扩展名。song_ 后必须是数字 id(排除 song_cov* 封面)。 + * 返回 0=是歌曲条目。 */ +static int parse_item_name(const char *nm, ncm_cache_item_t *it) +{ + const char *p; + size_t n = 0; + if (!nm || strncmp(nm, "song_", 5) != 0) return -1; + p = nm + 5; + while (p[n] >= '0' && p[n] <= '9' && n + 1 < sizeof it->id) { it->id[n] = p[n]; n++; } + if (n == 0 || (p[n] >= '0' && p[n] <= '9')) return -1; + it->id[n] = 0; + p += n; + it->level[0] = 0; + if (*p == '_') { + size_t m = 0; + p++; + while (*p && *p != '.' && m + 1 < sizeof it->level) it->level[m++] = *p++; + it->level[m] = 0; + } + while (*p == '.') p++; /* 历史双点名 song_..mp3 归一 */ + if (!*p) return -1; + ncm_strlcpy(it->ext, p, sizeof it->ext); + return 0; +} + +/* 请求档 + 实际容器参数 → 实际档(只降不升:显示「已降级至 X」语义)。 */ +static int actual_level_of(int req, const ncm_audio_info_t *ai) +{ + int cap; + if (!ai || !ai->container[0]) return req; /* 探测失败:保留请求档 */ + if (strcmp(ai->container, "flac") == 0) { + if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS; + else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER; /* 192k/24 */ + else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT; /* 96k/24 */ + else cap = NCM_LEVEL_HIRES; /* 48k/24 */ + } else if (strcmp(ai->container, "mp3") == 0) { + cap = (ai->bitrate_kbps > 128) ? NCM_LEVEL_EXHIGH : NCM_LEVEL_STANDARD; + } else { + return req; + } + if (req < 0) return cap; + return (req < cap) ? req : cap; +} + +static int cmp_item_mtime_desc(const void *a, const void *b) +{ + const ncm_cache_item_t *x = (const ncm_cache_item_t *)a; + const ncm_cache_item_t *y = (const ncm_cache_item_t *)b; + if (x->mtime > y->mtime) return -1; + if (x->mtime < y->mtime) return 1; + return strcmp(x->id, y->id); +} + +int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max) +{ + const char *store; + DIR *d; + struct dirent *e; + int n = 0; + + if (!c || !out || max <= 0) return NCM_ERR_ARG; + store = (c->songs_dir[0]) ? c->songs_dir : c->dir; + d = opendir(store); + if (!d) return NCM_ERR_IO; + while ((e = readdir(d))) { + ncm_cache_item_t it; + char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; + struct stat st; + ncm_audio_info_t ai; + ncm_tag_meta_t tag; + int slot; + + if (strncmp(e->d_name, "song_", 5) != 0) continue; + if (is_partial_name(e->d_name)) continue; /* .tmp/.part:写者管理域 */ + memset(&it, 0, sizeof it); + if (parse_item_name(e->d_name, &it) != 0) continue; + snprintf(p, sizeof p, "%s/%s", store, e->d_name); + if (stat(p, &st) != 0 || !S_ISREG(st.st_mode)) continue; + it.size = (uint64_t)st.st_size; + it.mtime = (int64_t)st.st_mtim.tv_sec; + if (n < max) { + slot = n; + } else { + /* 池内条目多于展示上限:只保留最近访问的 max 条 */ + int oldest = 0; + for (int i = 1; i < n; i++) + if (out[i].mtime < out[oldest].mtime) oldest = i; + if (it.mtime <= out[oldest].mtime) continue; + slot = oldest; + } + memset(&ai, 0, sizeof ai); + it.actual_level = actual_level_of(ncm_api_level_from_key(it.level), + (ncm_audio_probe_file(p, &ai) == NCM_OK) ? &ai : NULL); + if (ncm_tag_read_file(p, &tag) == NCM_OK) { /* 文件内 tag 优先(外部播放器同源) */ + ncm_strlcpy(it.name, tag.title, sizeof it.name); + ncm_strlcpy(it.artist, tag.artist, sizeof it.artist); + } + out[slot] = it; + if (n < max) n++; + } + closedir(d); + qsort(out, (size_t)n, sizeof *out, cmp_item_mtime_desc); + return n; +} + +int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext) +{ + char key[48]; + char path[NCM_CACHE_DIR_MAX + 64]; + + if (!c || !id || !id[0]) return NCM_ERR_ARG; + if (level && level[0]) snprintf(key, sizeof key, "%s_%s", id, level); + else ncm_strlcpy(key, id, sizeof key); + if (ncm_cache_path(c, key, (ext && ext[0]) ? ext : "mp3", path, sizeof path) != NCM_OK) + return NCM_ERR_FULL; + if (unlink(path) == 0) return NCM_OK; + /* §19 历史双点名 song_..(scan 已归一成单点 ext)兜底删除 */ + { + char legacy[NCM_CACHE_DIR_MAX + 64]; + const char *e = (ext && ext[0]) ? ext : "mp3"; + const char *base = cache_base_dir(c, key); + int k = snprintf(legacy, sizeof legacy, "%s/song_%s..%s", base, key, e); + if (k > 0 && (size_t)k < sizeof legacy && unlink(legacy) == 0) return NCM_OK; + } + return NCM_ERR_NOENT; +} diff --git a/patch_v2/ncm_app/core/ncm_cache.h b/patch_v2/ncm_app/core/ncm_cache.h index 85c2b25..c485edf 100644 --- a/patch_v2/ncm_app/core/ncm_cache.h +++ b/patch_v2/ncm_app/core/ncm_cache.h @@ -87,6 +87,30 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c); /* 立即执行 LRU 淘汰(扫描 songs/;超上限则删最旧,直到 <= 上限;跳过 .tmp/.part 半成品)。 */ int ncm_cache_evict(const ncm_cache_t *c); +/* 缓存条目(ncm_cache_scan 输出,供「已缓存列表」展示/点播/删除)。 + * 命名约定 song__<请求档位>.<实际格式>;**档位段是请求档,不是实际档**——真机存在 + * song_171650_hires.mp3(无损请求被降级链落到 exhigh mp3)。故 scan 用 ncm_audio_probe_file + * 读实际容器参数,actual_level 给**实际档**(-1=无法判定);播放/删除仍用 id+level+ext 定位。 */ +typedef struct ncm_cache_item { + char id[24]; /* 歌曲 id(数字串) */ + char level[20]; /* 文件名里的请求档位键(""=旧无档位键条目) */ + char ext[8]; /* 实际容器扩展名(mp3/flac,去点) */ + int actual_level; /* 实际档 NCM_LEVEL_*(按容器/位深/码率推断;-1=未知) */ + uint64_t size; /* 文件字节数(含嵌入 tag) */ + int64_t mtime; /* 最后访问/写入(LRU 序) */ + char name[96]; /* 文件内 tag 标题(无 tag 为空,调用方可回退旁路元数据) */ + char artist[64]; /* 文件内 tag 歌手 */ +} ncm_cache_item_t; + +/* 扫描歌曲缓存池(songs/,跳过 .tmp/.part 半成品与非 song_<数字 id> 条目),按 mtime 降序 + * (最近播放/写入在前)写入 out(最多 max 条;池内更多时保留最近的 max 条)。返回条目数或负错误码。 + * 每条会读文件头(tag + 音频参数探测),属磁盘 IO——**必须在 worker 线程调用**。 */ +int ncm_cache_scan(const ncm_cache_t *c, ncm_cache_item_t *out, int max); + +/* 删除单条缓存:id+level(level 可为 "")+ext 定位 song_[_].。 + * 返回 0=已删除;NCM_ERR_NOENT=不存在。 */ +int ncm_cache_remove(const ncm_cache_t *c, const char *id, const char *level, const char *ext); + /* 清空缓存池:删除 songs/ 与 covers/ 的 song_* 全部文件(音频 + 封面条目 + 残留 .tmp/.part)。 */ int ncm_cache_clear(const ncm_cache_t *c); From 39465ea1efc5e9fe35e7e61a0f40d1c9e9bb16e6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:35:09 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ncm):=20=E6=96=B0=E5=86=99=E5=85=A5?= =?UTF-8?q?=E7=9A=84=E7=BC=93=E5=AD=98=E6=96=87=E4=BB=B6=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2=E5=85=83=E6=95=B0=E6=8D=AE=EF=BC=88FLAC/MP3?= =?UTF-8?q?=EF=BC=8Ccommit=20=E5=89=8D=E6=B5=81=E5=BC=8F=E9=87=8D=E5=86=99?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E9=99=8D=E7=BA=A7=E8=A3=B8=E6=B5=81?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户需求:保留元数据分离的同时把歌曲元数据嵌进缓存文件(否则外部播放器读不到任何信息)。 - 接入点 player_manager:下载完整(dl_rc==OK)→ stream_src_finish(读端先自然收尾)→ ncm_tag_embed_file(.tmp, 实际扩展名, {name/art_name/album_name}) → dispose join 之后才 commit。tag 重写是同目录 .tag.tmp + rename 换 inode,解码端全程持旧 fd,播放/seek/ .part 续传字节语义零改动。 - 容器按实际 magic 分支(ncm_tag.c);峰值内存 64KB;失败只记 WARN、commit 裸流, 播放主链路不受影响(用户红线)。 - 元数据分离体系不动:/meta/.bin 旁路照旧写读;LRU 只 stat 文件, 无 size/etag 索引需回填(重写后 stat 自然一致)。 - test_ncm_core 新增 test_cache_tag:writer 写裸流 → 嵌入 ID3v2.3 / VORBIS_COMMENT → 读回 → ncm_cache_scan(hires 名 + 320k mp3 体 → 实际档 EXHIGH,名称来自文件内 tag) → ncm_cache_remove → ncm_cache_clear。 - REPORT §38:设计/内存估算/验证/诚实遗留(本机缺 vendor 与交叉工具链,未跑整链与真机)。 --- patch_v2/ncm_app/REPORT.md | 76 ++++++++++++ patch_v2/ncm_app/app/play/player_manager.c | 21 +++- patch_v2/ncm_app/test/test_ncm_core.c | 138 +++++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index bfbf980..12a9f91 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -3293,3 +3293,79 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进 - **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。 - **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。 - **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。 + +## 38. M5:已缓存列表页 + 缓存文件内元数据嵌入(2026-11-04) + +用户原话:「优化一下网易云的部分逻辑。在首页顶栏新加一个已缓存列表入口。另外,缓存文件的配置改一下: +在保留元数据分离的同时,将歌曲元数据嵌入缓存文件中(不然在其他播放器中读不到任何信息)。」 + +### 38.1 功能①:首页顶栏「已缓存」入口 + 缓存列表页 + +- 首页 topbar 新增动作 3(`MEOW_ICON_DOWNLOAD` 位图,`app/ui/home.c`),原「回播放页」顺延为动作 4; + 点击 `cache_open()` → push `ACT_CACHE`(新 activity id 7)。 +- 新页 `app/ui/cache.c`(对齐 queue.c 惯例:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮): + - 行:歌名 / `歌手 · 实际档位 · 大小`;空态「暂无缓存」;页脚「N 首 · 合计 X MB」。 + - 点行 = 点播(切 `NCM_QUEUE_CACHE` 队列,上/下一首在缓存列表内循环);行右「删除」= 单曲删除; + 顶栏「清空」= 确认框 + `ncm_cache_clear`(与设置页「清理缓存」同语义:音频 + 封面条目)。 + - **数据全在 worker**:`cache_reload` 起线程 → `appstate_cache_list_load()` → + `ncm_cache_scan()`(读目录 + 文件头 tag + 音频参数探测)→ 快照;UI 线程只读快照(零磁盘 IO)。 +- 播放链路:`NCM_QUEUE_CACHE`(`appstate_queue_count/song`、`player_play_cache_song`); + `player_manager.c` 的 `song_local_file` 对缓存队列用**条目真实键**(id + 文件名请求档 + 实际扩展名) + 直接命中缓存文件(不按用户当前档键重下),`eff_level` 回填实际档。 + +### 38.2 功能②:缓存文件内元数据嵌入(保留元数据分离) + +- 新模块 `core/ncm_tag.c/.h`(纯 libc,宿主可单测): + - **FLAC → VORBIS_COMMENT**(type 4,TITLE/ARTIST/ALBUM;插在既有元数据块之后、音频帧之前, + 旧 VORBIS_COMMENT 被替换、PADDING/PICTURE 等原样保留); + - **MP3 → ID3v2.3**(TIT2/TPE1/TALB,UTF-16LE + BOM,中英文/emoji 均可;源已带 ID3v2 时先剥旧标签)。 + - **按实际容器分支**(读 `fLaC` 魔数)——缓存名里的档位段是**请求档**,真机存在 + `song_171650_hires.mp3`(无损请求被降级链落到 exhigh mp3),不能按名字判容器。 + - 写入 = 同目录 `.tag.tmp` + 64KB 分块搬运 + fsync + rename 原子替换;失败即 unlink 临时件、 + 原文件不变 → 调用方 commit 裸流(**降级不影响播放主链路**)。 +- 接入点 `app/play/player_manager.c`:下载完整(`dl_rc==OK`)→ `stream_src_finish`(读端先自然收尾)→ + `ncm_tag_embed_file(.tmp, ext, {name, art_name, album_name})` → 播放线程 `dispose` join 后才 commit。 + 解码端全程持有原 inode 的 fd(tag 重写是 rename 换 inode),播放/seek/续传字节语义零改动。 +- **元数据分离体系不动**:`/meta/.bin` 旁路照旧写读;LRU 只 stat 文件(无 size 索引, + 故无「回填 size/etag」需求——重写后 stat 自然得到新 size)。 + +### 38.3 实际档位(用户/塔台取证要求) + +`ncm_audio_probe_file`(ncm_tag.c)只读文件头:FLAC STREAMINFO(采样率/位深)、MP3 首帧(码率); +`ncm_cache_scan` 用「请求档 ∩ 实际格式能力」得实际档(只降不升): +FLAC 16bit→无损 SQ;24bit/48k→Hi-Res;24bit/96k→高清臻音;24bit/192k→超清母带; +MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires 名 + mp3 体 → 极高 HQ)。 + +### 38.4 内存(设备 56MB,硬约束) + +| 项 | 峰值 | +|---|---| +| tag 嵌入(每次 commit 前) | malloc **64KB** 分块缓冲 + ~2KB 栈(ID3 构建)+ 结构体 ≈ **68KB 瞬时**,完毕即释放 | +| 缓存页扫描(worker) | 快照临时数组 512×232B ≈ **116KB** + 单文件 tag/探测 64KB 顺序复用 ≈ **180KB 瞬时** | +| 常驻 bss | `g_st.cache_items[512]` ≈ **116KB**(+8.5% 于原 1364516B bss;RSS 占比 <0.3%) | + +大文件不整读:FLAC 元数据块/PICTURE、MP3 标签区、音频体全部 ≤64KB 分块;tag 读取只读文件头(≤64KB)。 + +### 38.5 验证 + +| 项 | 结果 | +|---|---| +| 新 `test/test_ncm_tag.c` + `build_ncm_tag_host.sh`(FLAC/MP3 构建+读回、中文 roundtrip、旧标签替换、容器判定、3MB 分块、失败降级、音频头探测) | host **51 PASS / 0 FAIL** | +| `test_ncm_core.c` 新增 `test_cache_tag`(writer→embed→read→scan→remove→clear,含 hires 名/mp3 体实际档断言) | 已加入 core 测试集(本机缺 vendor mbedtls/jsmn 无法整链,见 38.6) | +| 临时单测(真实 ncm_cache.c + ncm_api.c + ncm_tag.c,仅桩 `ncm_strlcpy`/`mkdir_p`) | 16 PASS / 0 FAIL(scan 两条、actual=EXHIGH/HIRES、name/artist 来自文件内 tag、remove/clear) | +| 全部改动文件 `gcc -Wall -Wextra`(core + app + tests,-Wpedantic 对 ncm_tag) | **0 warning** | +| `build_ncm.sh` / `build_core.sh` / `build_ncm_host.sh` 源列表 | 已加入 `core/ncm_tag.c`、`app/ui/cache.c`(交叉/宿主编译本机缺工具链与 vendor,未执行) | + +### 38.6 诚实出入 / 遗留 + +- **交叉与整链测试未在本机执行**:worktree 里 `vendor/mbedtls|jsmn|minimp3|qrcodegen` 均被 .gitignore 排除且 + 本机无交叉工具链(`libs_common.sh` 指向 /home/Aska/Project/R3ProII/.tools)。故 `build_core.sh`/ + `build_ncm.sh`/`build_ncm_host.sh` 与本仓库 core 测试**未运行**;已用真实源码单独编译(0 warning)并跑 + ncm_tag 全量与临时 cache-scan 单测覆盖。**需在构建环境补跑 `build_core.sh` 的 host+mipsel 两套测试**。 +- **未上真机**:不碰真机 Music/MeowData(红线)。ta 嵌入后「外部播放器可读」「minimp3/dr_flac 读带 tag 的 + 缓存文件仍正常」需真机抽验;minimp3_ex 对 ID3v2 有跳过逻辑(VENDOR_NOTES:「minimp3_ex 带 ID3/CRC 处理」), + dr_flac 原生解析 VORBIS_COMMENT 元数据块,理论兼容,但未实测。 +- **tag 重写是一次额外整文件读+写**(TF 顺序 IO):在下载线程内、播放收尾前完成;短曲/快速拖动到底时 + dispose 的 join 可能多等 ~0.3–1s。磁盘瞬时要 2× 单曲空间(失败自动降级裸流)。 +- **缓存队列点播的时长/封面**取自旁路 `meta/.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。 +- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。 diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 9a13716..ad3d874 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -349,6 +349,11 @@ typedef struct { char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */ size_t maxb; /* 下载限幅 */ int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */ + /* 缓存文件元数据嵌入(NCM_CACHE_DESIGN §2.5 + M5):下载完整后由下载线程把 + * id3/vorbis tag 写进 .tmp(按实际容器分支),再交播放线程 commit。失败只记日志, + * commit 裸流——播放主链路不受影响。 */ + ncm_tag_meta_t tag; + char ext[8]; /* 实际容器(song_url type / url 扩展名) */ /* 阶段3 .part 续传(§5.2) */ uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=- */ int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */ @@ -441,8 +446,16 @@ static void *dl_thread(void *arg) if (!ok_st) rc = NCM_ERR_HTTP; } ss->dl_rc = rc; - /* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */ + /* 缓存文件元数据嵌入(M5):下载完整 → 把 id3/vorbis tag 流式写进 .tmp(64KB 分块, + * 同 inode 替换:解码端持有旧 fd 继续播,不受影响)。先 finish 让读端自然收尾,再重写; + * dispose 的 join 保证「重写完成 → commit」顺序。任何失败都只降级为裸流。 */ stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc); + if (rc == NCM_OK && ss->w && !g_app_quit) { + int trc = ncm_tag_embed_file(ncm_cache_writer_tmp_path(ss->w), ss->ext, &ss->tag); + if (trc != NCM_OK) + meow_log(MEOW_LOG_WARN, "play: tag embed skipped rc=%d ext=%s (raw stream kept)", + trc, ss->ext); + } return NULL; } @@ -548,6 +561,12 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch const char *ext = "mp3"; if (ncm_streq(u.type, "flac")) ext = "flac"; else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac"; + ncm_strlcpy(ss->ext, ext, sizeof ss->ext); + /* 文件内 tag 字段(下载完整后由 dl_thread 写入;空字段不写)。 */ + memset(&ss->tag, 0, sizeof ss->tag); + ncm_strlcpy(ss->tag.title, s->name, sizeof ss->tag.title); + ncm_strlcpy(ss->tag.artist, s->art_name, sizeof ss->tag.artist); + ncm_strlcpy(ss->tag.album, s->album_name, sizeof ss->tag.album); /* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */ ncm_cache_evict(&g_st.ctx.cache); diff --git a/patch_v2/ncm_app/test/test_ncm_core.c b/patch_v2/ncm_app/test/test_ncm_core.c index 18477b5..396037f 100644 --- a/patch_v2/ncm_app/test/test_ncm_core.c +++ b/patch_v2/ncm_app/test/test_ncm_core.c @@ -168,6 +168,143 @@ static void test_cache(void) { } } +/* ---------- M5:缓存文件内元数据嵌入 + 已缓存列表扫描 ---------- + * 样本全手工构造(不引第三方库):FLAC = "fLaC" + STREAMINFO(34B) + 假帧;MP3 = 帧同步 + 假帧。 + * 断言链路:writer 写裸流 → ncm_tag_embed_file 嵌 tag → ncm_tag_read_file 读回 → + * ncm_cache_scan 列出(名称来自文件内 tag、档位=实际档)→ ncm_cache_remove/clear。 */ +static void make_flac_bytes(unsigned char *buf, size_t *len, int bps) +{ + size_t o = 0; + memset(buf, 0, 34 + 12 + 512); + memcpy(buf + o, "fLaC", 4); o += 4; + buf[o++] = 0x80; buf[o++] = 0x00; buf[o++] = 0x00; buf[o++] = 34; /* STREAMINFO, last */ + unsigned char *si = buf + o; + si[0] = 0x10; si[1] = 0x00; si[2] = 0x10; si[3] = 0x00; + si[10] = 0x0A; si[11] = 0xC4; /* 44100 */ + si[12] = (unsigned char)(0x40 | 0x02 | (((bps - 1) >> 4) & 0x01)); /* 2ch + bps 高位 */ + si[13] = (unsigned char)(((bps - 1) & 0x0F) << 4); + si[16] = 0xAC; si[17] = 0x44; /* total samples */ + o += 34; + for (int i = 0; i < 512; i++) buf[o++] = (unsigned char)(0xF8 ^ i); + *len = o; +} + +static void test_cache_tag(void) { + printf("== cache tag embed + scan (M5) ==\n"); + ncm_cache_t c; + char dir[] = "/tmp/ncm_tag_cache"; + { + char cmd[160]; + snprintf(cmd, sizeof cmd, "rm -rf %s; mkdir -p %s", dir, dir); + if (system(cmd) != 0) { /* best-effort */ } + } + ncm_cache_init(&c, dir); + + /* ① MP3 条目:文件名带请求档 hires、实际是 320kbps mp3(真机 song__hires.mp3 形态) */ + { + unsigned char mp3[600]; + memset(mp3, 0x11, sizeof mp3); + mp3[0] = 0xFF; mp3[1] = 0xFB; mp3[2] = 0xE0; mp3[3] = 0x00; /* 320kbps / 44.1kHz */ + ncm_cache_writer_t *w; + CHECKRC(ncm_cache_open(&c, "171650_hires", "mp3", &w), "open cache entry (hires name/mp3 body)"); + CHECKRC(ncm_cache_write(w, mp3, sizeof mp3), "write raw mp3"); + CHECKRC(ncm_cache_commit(w), "commit"); + + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path), "lookup entry"); + ncm_tag_meta_t m, got; + memset(&m, 0, sizeof m); + snprintf(m.title, sizeof m.title, "晴天"); + snprintf(m.artist, sizeof m.artist, "周杰伦"); + snprintf(m.album, sizeof m.album, "叶惠美"); + CHECKRC(ncm_tag_embed_file(path, "mp3", &m), "embed ID3v2.3"); + memset(&got, 0, sizeof got); + CHECKRC(ncm_tag_read_file(path, &got), "read back embedded tag"); + CHECK(strcmp(got.title, "晴天") == 0 && strcmp(got.artist, "周杰伦") == 0 && + strcmp(got.album, "叶惠美") == 0, + "tag roundtrip (title=%s artist=%s)", got.title, got.artist); + struct stat st; + CHECK(stat(path, &st) == 0 && st.st_size > (off_t)sizeof mp3, + "file grew by tag bytes (%ld > %zu)", (long)st.st_size, sizeof mp3); + ncm_audio_info_t ai; + CHECK(ncm_audio_probe_file(path, &ai) == NCM_OK && + strcmp(ai.container, "mp3") == 0 && ai.bitrate_kbps == 320, + "probe actual container after tagging (%s %dkbps)", ai.container, ai.bitrate_kbps); + } + + /* ② FLAC 条目:24bit/44.1k → 实际档应判 Hi-Res(请求档写 hires 亦一致) */ + { + unsigned char fl[700]; + size_t flen = 0; + make_flac_bytes(fl, &flen, 24); + ncm_cache_writer_t *w; + CHECKRC(ncm_cache_open(&c, "2001_hires", "flac", &w), "open flac cache entry"); + CHECKRC(ncm_cache_write(w, fl, flen), "write raw flac"); + CHECKRC(ncm_cache_commit(w), "commit flac"); + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_lookup(&c, "2001_hires", "flac", path, sizeof path), "lookup flac entry"); + ncm_tag_meta_t m, got; + memset(&m, 0, sizeof m); + snprintf(m.title, sizeof m.title, "夜曲"); + snprintf(m.artist, sizeof m.artist, "周杰伦"); + CHECKRC(ncm_tag_embed_file(path, "flac", &m), "embed VORBIS_COMMENT"); + memset(&got, 0, sizeof got); + CHECKRC(ncm_tag_read_file(path, &got), "read back flac tag"); + CHECK(strcmp(got.title, "夜曲") == 0, "flac tag roundtrip (title=%s)", got.title); + } + + /* ③ 扫描:两条;名称来自文件内 tag;档位=实际档(mp3 320k → EXHIGH,不显示请求档 hires) */ + { + ncm_cache_item_t items[8]; + int n = ncm_cache_scan(&c, items, 8); + CHECK(n == 2, "scan lists 2 cached entries (got %d)", n); + int seen_mp3 = 0, seen_flac = 0; + for (int i = 0; i < n; i++) { + if (strcmp(items[i].id, "171650") == 0) { + seen_mp3 = 1; + CHECK(strcmp(items[i].level, "hires") == 0, "requested level kept in name (%s)", + items[i].level); + CHECK(items[i].actual_level == NCM_LEVEL_EXHIGH, + "actual level = EXHIGH for 320k mp3 (got %d)", items[i].actual_level); + CHECK(strcmp(items[i].name, "晴天") == 0 && + strcmp(items[i].artist, "周杰伦") == 0, + "scan name/artist from embedded tag (%s/%s)", items[i].name, items[i].artist); + CHECK(strcmp(items[i].ext, "mp3") == 0 && items[i].size > 0, "scan ext/size"); + } else if (strcmp(items[i].id, "2001") == 0) { + seen_flac = 1; + CHECK(items[i].actual_level >= NCM_LEVEL_HIRES, + "actual level = Hi-Res+ for 24bit flac (got %d)", items[i].actual_level); + CHECK(strcmp(items[i].name, "夜曲") == 0, "flac scan name (%s)", items[i].name); + } + } + CHECK(seen_mp3 && seen_flac, "both entries present (mp3=%d flac=%d)", seen_mp3, seen_flac); + } + + /* ④ 单曲删除 + LRU 淘汰(带 tag 文件照常计入池)+ 清空 */ + { + char path[NCM_CACHE_DIR_MAX + 64]; + CHECKRC(ncm_cache_remove(&c, "171650", "hires", "mp3"), "remove single entry"); + CHECK(ncm_cache_lookup(&c, "171650_hires", "mp3", path, sizeof path) == NCM_ERR_NOENT, + "removed entry gone"); + ncm_cache_item_t items[8]; + CHECK(ncm_cache_scan(&c, items, 8) == 1, "scan after remove -> 1"); + /* 兼容性:嵌入 tag 的文件照常计入 LRU 池、按 mtime 被淘汰(旧无 tag 条目同理) */ + CHECK(ncm_cache_total_bytes(&c) > 0, "tagged entry counted in LRU pool"); + c.limit_bytes = 1; /* 任何条目都超限 → 必淘汰 */ + CHECKRC(ncm_cache_evict(&c), "evict with tagged entry"); + CHECK(ncm_cache_scan(&c, items, 8) == 0, "tagged entry evicted by LRU"); + ncm_cache_set_limit_gb(&c, 10); + { + ncm_cache_writer_t *w2; + ncm_cache_open(&c, "3001_exhigh", "mp3", &w2); + ncm_cache_write(w2, (const unsigned char *)"X", 1); + ncm_cache_commit(w2); + } + CHECKRC(ncm_cache_clear(&c), "clear cache pool"); + CHECK(ncm_cache_scan(&c, items, 8) == 0, "scan after clear -> 0"); + } +} + static void test_qr(void) { printf("== qrcodegen ==\n"); unsigned char mods[177 * 177]; @@ -541,6 +678,7 @@ int main(int argc, char **argv) { test_crypto_vectors(); test_store_cookie(); test_cache(); + test_cache_tag(); test_qr(); test_cookie_merge(); test_eapi_musicu_hdr(); From a9c94c7221fb2df822455f7c351c98442a30559a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:36:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ncm):=20tag=20=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=8C=89=E5=AE=9E=E9=99=85=E5=AE=B9=E5=99=A8=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=20MP4/M4A=EF=BC=88ID3v2=20=E5=89=8D=E7=BC=80=E4=BC=9A=E7=A0=B4?= =?UTF-8?q?=E5=9D=8F=20MP4=20=E5=AE=B9=E5=99=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit download 扩展名在 type 非 flac 时一律落 mp3;若服务端降级返回 MP4/M4A("....ftyp"), 给文件加 ID3v2 头会破坏容器。ncm_tag_embed_file 现读 12 字节 magic:fLaC → VORBIS_COMMENT, "ftyp" → NCM_ERR_ARG 拒绝(调用方 commit 裸流),其余按 MP3 处理。 test_ncm_tag 增 2 条断言(拒绝 + 原文件不变)→ 53 PASS / 0 FAIL,0 warning。 --- patch_v2/ncm_app/core/ncm_tag.c | 10 +++++++--- patch_v2/ncm_app/test/test_ncm_tag.c | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/patch_v2/ncm_app/core/ncm_tag.c b/patch_v2/ncm_app/core/ncm_tag.c index a439839..f79f56e 100644 --- a/patch_v2/ncm_app/core/ncm_tag.c +++ b/patch_v2/ncm_app/core/ncm_tag.c @@ -561,9 +561,10 @@ int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t * { char tmp[NCM_TAG_PATH_MAX]; struct stat st; - unsigned char magic[4]; + unsigned char magic[12]; unsigned char *buf; int in, out, rc, is_flac, n; + ssize_t mgot; if (!path || !path[0] || !m) return NCM_ERR_ARG; if (!m->title[0] && !m->artist[0] && !m->album[0]) return NCM_OK; /* 无元数据:不动文件 */ @@ -579,12 +580,15 @@ int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t * 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; } + mgot = read_full(in, magic, sizeof magic); + if (mgot < 4) { close(in); return NCM_ERR_IO; } if (lseek(in, 0, SEEK_SET) < 0) { close(in); return NCM_ERR_IO; } is_flac = (memcmp(magic, "fLaC", 4) == 0); /* 声明 flac 却无 fLaC 魔数 = 残体/损坏:拒绝写(调用方 commit 裸流降级)。 - * 声明 mp3 而内容实为 FLAC:按容器写 VORBIS_COMMENT(真机降级产物同理)。 */ + * 声明 mp3 而内容实为 FLAC:按容器写 VORBIS_COMMENT(真机降级产物同理)。 + * MP4/M4A(第 4..7 字节 "ftyp"):ID3v2 头会破坏 MP4 容器 → 拒绝(裸流降级)。 */ if (!is_flac && ext && strcasecmp(ext, "flac") == 0) { close(in); return NCM_ERR_ARG; } + if (!is_flac && mgot >= 8 && memcmp(magic + 4, "ftyp", 4) == 0) { close(in); return NCM_ERR_ARG; } buf = (unsigned char *)malloc(NCM_TAG_CHUNK); if (!buf) { close(in); return NCM_ERR_OOM; } diff --git a/patch_v2/ncm_app/test/test_ncm_tag.c b/patch_v2/ncm_app/test/test_ncm_tag.c index 81df582..a7f1403 100644 --- a/patch_v2/ncm_app/test/test_ncm_tag.c +++ b/patch_v2/ncm_app/test/test_ncm_tag.c @@ -347,6 +347,13 @@ static void test_edges(void) snprintf(tmp, sizeof tmp, "%s.tag.tmp", path); CHECK(stat(tmp, &st) != 0, "no .tag.tmp left behind"); } + /* MP4/M4A("....ftyp")不是 mp3:ID3v2 前缀会破坏 MP4 容器 → 必须拒绝(降级裸流) */ + snprintf(path, sizeof path, TMPDIR_PREFIX "/fake.m4a.mp3"); + file_put(path, "\x00\x00\x00\x20""ftypisom\x00\x00\x00\x00", 16); + CHECK(ncm_tag_embed_file(path, "mp3", &m) != 0, "MP4 container rejected (no ID3 prefix)"); + before = file_slurp(path, &blen); + CHECK(blen == 16 && memcmp(before + 4, "ftyp", 4) == 0, "MP4 file unchanged"); + free(before); /* 不存在的文件 */ CHECK(ncm_tag_embed_file(TMPDIR_PREFIX "/nope.mp3", "mp3", &m) != 0, "missing file -> error"); CHECK(ncm_tag_read_file(TMPDIR_PREFIX "/nope.mp3", &m) == NCM_ERR_NOENT,