Files
MeowOS-For-R3P2/patch_v2/reader_app/app/shelf_model.c
T
AskaEth e0dfab95c3 perf(reader): M32 书架索引落盘 — 冷启动只 stat 434 个目录 mtime,跳过 readdir(3.0s→~0.15s)
M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s,
其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到
MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即
完全跳过 readdir;任何异常一律 fail-open 全量重扫。

先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级)
- 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir);
- 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。
真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms,
远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。
strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。
生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows)
≈ 0.13–0.15s(对比 3.0s)。

索引格式 v1(手工小端序列化,不依赖结构体布局):
header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s,
mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags,
depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时
如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。

失效语义(存每个扫描过的目录,不只 level-1):
- level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫;
- 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫;
- 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示),
  点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的
  同刻度变更也可能漏判,点「刷新」必可纠正。
M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子
写回新索引)。

安全/健壮(宁可慢不可错):
- 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引
  要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。
- 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖
  全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime
  任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。
- writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书;
  commit 失败(含只读目录 rename 失败)不留正式索引。

测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/
缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、
只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净;
test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、
truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning
(core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 /
loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与
MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。

交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf
1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
2026-11-10 18:14:52 +08:00

327 lines
12 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */
#include "shelf_model.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int shelf_fmt_from_ext(const char *name)
{
const char *d = strrchr(name, '.');
if (!d) return -1;
if (strcasecmp(d, ".txt") == 0) return 0;
if (strcasecmp(d, ".fb2") == 0) return 1;
if (strcasecmp(d, ".epub") == 0) return 2;
if (strcasecmp(d, ".mobi") == 0) return 3;
if (strcasecmp(d, ".cbz") == 0) return 4;
return -1;
}
const char *shelf_fmt_name(int f)
{
switch (f) {
case 0: return "TXT";
case 1: return "FB2";
case 2: return "EPUB";
case 3: return "MOBI";
case 4: return "CBZ";
default: return "?";
}
}
void shelf_fmt_size(char *out, int outsz, long size)
{
if (!out || outsz <= 0) return;
if (size >= 1 << 20) snprintf(out, (size_t)outsz, "%.1fMB", (double)size / (1 << 20));
else if (size >= 1 << 10) snprintf(out, (size_t)outsz, "%.0fKB", (double)size / (1 << 10));
else snprintf(out, (size_t)outsz, "%ldB", size);
}
int shelf_copy_utf8(char *dst, int cap, const char *src)
{
if (!dst || cap <= 0) return 0;
if (!src) { dst[0] = 0; return 0; }
int limit = cap - 1;
int n = 0;
while (src[n] && n < limit) n++;
if (src[n]) {
/* 截断:若 src[n] 是续字节(10xxxxxx),回退到该字符首字节之前,整段丢弃。 */
while (n > 0 && ((unsigned char)src[n] & 0xC0) == 0x80) n--;
}
memcpy(dst, src, (size_t)n);
dst[n] = 0;
return n;
}
static int cmp_recent_then_name(const void *a, const void *b)
{
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
return strcasecmp(x->title, y->title);
}
/* 最近阅读排序(扫描后 / 刚读完置顶共用同一口径)。 */
void shelf_sort_recent(shelf_book *books, int nbooks)
{
if (!books || nbooks < 2) return;
qsort(books, (size_t)nbooks, sizeof(shelf_book), cmp_recent_then_name);
}
/* 重建一本书的两种副题(扫描后与「刚读完置顶」共用同一份格式化逻辑)。
* sub = 格式 · 大小 [· 读](format 模式/宫格)
* sub_folder = 相对 book.group 第一级之后的子路径 · sub(folder 模式)
*/
void shelf_fmt_sub(shelf_book *b)
{
if (!b) return;
char sz[24];
shelf_fmt_size(sz, sizeof sz, b->size);
/* 默认副题:与 M27 前逐字一致(format 模式/宫格都用它)。 */
snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(b->fmt), sz,
b->ts ? " · 读" : "");
/* folder 副题:第一级之后的子路径(分类/作品)前置,其余同默认副题。 */
const char *rest = strchr(b->group, '/');
if (rest) rest++;
if (rest && rest[0]) {
char pre[100];
shelf_copy_utf8(pre, (int)sizeof pre, rest);
snprintf(b->sub_folder, sizeof b->sub_folder, "%s · %s", pre, b->sub);
} else {
snprintf(b->sub_folder, sizeof b->sub_folder, "%s", b->sub);
}
}
typedef struct scan_ctx {
shelf_book *books;
int cap;
int n;
int truncated;
int depth_skipped;
shelf_ts_fn ts_fn;
shelf_dir_fn dir_fn; /* M32:目录 mtime 采集(NULL=不采集) */
void *dir_ctx;
} scan_ctx;
/* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限,
* 防符号链接/异常深目录把计数也拖死。返回书籍文件数。 */
static int count_books_under(const char *dir, int depth)
{
if (depth > SHELF_SCAN_MAX_DEPTH + 8) return 0;
int n = 0;
DIR *dp = opendir(dir);
if (!dp) return 0;
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.') continue;
char path[400];
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
struct stat st;
if (stat(path, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) { n += count_books_under(path, depth + 1); continue; }
if (shelf_fmt_from_ext(de->d_name) >= 0) n++;
}
closedir(dp);
return n;
}
/* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""),
* top = 第一级目录名(root 自身/根下文件为 "")。
* dst = 本目录的 stat 结果(调用方已 stat;NULL=没有 mtime 可上报)。
* M32:扫描完一个目录后用 dir_fn 上报 (rel, mtime, 本目录收录书数),供索引做失效校验。
* books_here 只数**实际写进 books[] 的**书(被 cap 丢掉的不计),保证索引里
* sum(books_here) == nbooks,载入时可用它做一致性校验。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel,
const char *top, const struct stat *dst)
{
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
DIR *dp = opendir(dir);
if (!dp) return;
int books_here = 0;
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.') continue;
char path[400];
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
struct stat st;
if (stat(path, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) {
if (depth + 1 > SHELF_SCAN_MAX_DEPTH) {
c->depth_skipped += count_books_under(path, depth + 1); /* 超深:计数→可见提示 */
continue;
}
if (c->n >= c->cap) {
/* 上限卡在目录边界:探一下这个子树是否真有书,有书才算被截断。
* 只探空目录不置位,避免“正好装满且后面全是空目录”误报。 */
if (!c->truncated && count_books_under(path, depth + 1) > 0) c->truncated = 1;
if (c->truncated) break; /* 已确认丢书,后面的目录/文件不必再跑 */
continue;
}
char subrel[SHELF_GROUP_MAX * 16];
snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name);
/* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top, &st);
continue;
}
int f = shelf_fmt_from_ext(de->d_name);
if (f < 0) continue;
if (c->n >= c->cap) { c->truncated = 1; break; } /* 超限:可见提示,不静默 */
shelf_book *b = &c->books[c->n];
shelf_copy_utf8(b->path, (int)sizeof b->path, path);
shelf_copy_utf8(b->title, (int)sizeof b->title, de->d_name);
char *dot = strrchr(b->title, '.');
if (dot) *dot = 0; /* 标题去扩展名 */
shelf_copy_utf8(b->group, (int)sizeof b->group, rel);
shelf_copy_utf8(b->top, (int)sizeof b->top, top);
b->fmt = f;
b->size = (long)st.st_size;
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
shelf_fmt_sub(b); /* 副题:格式 · 大小 [· 读](+ folder 子路径) */
c->n++;
books_here++;
}
closedir(dp);
if (c->dir_fn && dst)
c->dir_fn(c->dir_ctx, rel, (int64_t)dst->st_mtime,
(long)dst->st_mtim.tv_nsec, books_here);
}
int shelf_scan_books_ex(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep,
shelf_dir_fn dir_fn, void *dir_ctx)
{
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; }
if (!root || !books || cap <= 0) {
if (rep) rep->root_missing = 1;
return 0;
}
DIR *dp = opendir(root);
if (!dp) {
if (rep) rep->root_missing = 1;
return 0;
}
closedir(dp);
/* root 自身的 mtime 也要上报(新增/删除 Books/ 下的一级目录会改它)。 */
struct stat rst;
int have_rst = (stat(root, &rst) == 0);
scan_ctx c;
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0;
c.ts_fn = ts_fn; c.dir_fn = dir_fn; c.dir_ctx = dir_ctx;
scan_dir(&c, root, 0, "", "", have_rst ? &rst : NULL);
shelf_sort_recent(books, c.n);
if (rep) {
rep->nbooks = c.n;
rep->truncated = c.truncated;
rep->depth_skipped = c.depth_skipped;
}
return c.n;
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
{
return shelf_scan_books_ex(root, books, cap, ts_fn, rep, NULL, NULL);
}
/* ---- 行序列 ---- */
/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */
static int top_len(const char *s)
{
const char *p = strchr(s, '/');
return p ? (int)(p - s) : (int)strlen(s);
}
static int top_cmp(const char *a, const char *b)
{
int la = top_len(a), lb = top_len(b);
if (!la != !lb) return la ? 1 : -1; /* 根组 "" 恒置顶 */
int m = la < lb ? la : lb;
int c = strncasecmp(a, b, (size_t)m);
if (c) return c;
return la - lb;
}
static int top_eq(const char *a, const char *b) { return top_cmp(a, b) == 0; }
/* 段头排序:根组(top=="")恒置顶,其余按第一级目录名不区分大小写排序。 */
static int cmp_group_key(const void *pa, const void *pb)
{
return top_cmp(*(const char *const *)pa, *(const char *const *)pb);
}
static int build_rows_format(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
static const int fmts[] = { 0, 1, 2, 3, 4 };
int nrows = 0;
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
int fmt = fmts[fi];
int have = 0;
if (nrows >= rowcap) break;
for (int i = 0; i < nbooks; i++) {
if (books[i].fmt != fmt) continue;
if (!have) {
if (nrows >= rowcap) break;
rows[nrows].section = 1; rows[nrows].fmt = fmt;
rows[nrows].book = -1; rows[nrows].label = NULL;
nrows++;
have = 1;
}
if (nrows >= rowcap) break;
rows[nrows].section = 0; rows[nrows].fmt = fmt;
rows[nrows].book = i; rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
static int build_rows_folder(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
/* 去重分组键(books[].top,""=根组)。键指向 books 内字符串,生存期覆盖 rows。 */
static const char *keys[SHELF_MAX_BOOKS];
int nkeys = 0;
for (int i = 0; i < nbooks; i++) {
const char *k = books[i].top;
int seen = 0;
for (int j = 0; j < nkeys; j++) {
if (top_eq(keys[j], k)) { seen = 1; break; }
}
if (!seen) keys[nkeys++] = k;
}
qsort(keys, (size_t)nkeys, sizeof keys[0], cmp_group_key);
int nrows = 0;
for (int ki = 0; ki < nkeys; ki++) {
if (nrows >= rowcap) break;
rows[nrows].section = 1;
rows[nrows].fmt = -1;
rows[nrows].book = -1;
rows[nrows].label = keys[ki][0] ? keys[ki] : SHELF_GROUP_ROOT;
nrows++;
for (int i = 0; i < nbooks; i++) {
if (!top_eq(books[i].top, keys[ki])) continue;
if (nrows >= rowcap) break;
rows[nrows].section = 0;
rows[nrows].fmt = books[i].fmt;
rows[nrows].book = i;
rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap)
{
if (!books || !rows || nbooks <= 0 || rowcap <= 0) return 0;
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
return folder_mode ? build_rows_folder(books, nbooks, rows, rowcap)
: build_rows_format(books, nbooks, rows, rowcap);
}