/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。 * * 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构: * - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环); * - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出; * - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 → * 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。 * * 懒判定(probe): * - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图; * - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的 * archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项 * LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。 * - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成, * 书行再拼 M7 的「· 续读 Pn」角标。 * * M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数); * 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。 */ #include "library.h" #include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include #include #include #include #include #include #include #ifdef MR_LIBRARY_TEST_HOOKS static mr_library_io_stats g_io; void mr_library_test_io_reset(void) { memset(&g_io, 0, sizeof g_io); } void mr_library_test_io_get(mr_library_io_stats *out) { if (out) *out = g_io; } #define IO_OPEN() (g_io.opendirs++) #define IO_READDIR() (g_io.readdirs++) #define IO_LSTAT() (g_io.lstats++) #else #define IO_OPEN() ((void)0) #define IO_READDIR() ((void)0) #define IO_LSTAT() ((void)0) #endif /* 系统目录不参与**册间衔接**(M7 兄弟册表);浏览本身已放开(不隐藏)。 */ static int ignored_dir_name(const char *name) { static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" }; if (!name || name[0] == '.') return 1; for (unsigned i = 0; i < sizeof skip / sizeof skip[0]; i++) if (strcmp(name, skip[i]) == 0) return 1; return 0; } static int copy_str(char *dst, int dstsz, const char *src) { if (!dst || dstsz <= 0) return -1; if (!src) { dst[0] = '\0'; return 0; } int n = (int)strlen(src); if (n >= dstsz) return -1; memcpy(dst, src, (size_t)n + 1); return 0; } static int join_path(char *out, int outsz, const char *dir, const char *name) { if (!out || outsz <= 0 || !dir || !name) return -1; int n; size_t dl = strlen(dir); if (dl > 0 && dir[dl - 1] == '/') n = snprintf(out, (size_t)outsz, "%s%s", dir, name); else n = snprintf(out, (size_t)outsz, "%s/%s", dir, name); return (n > 0 && n < outsz) ? 0 : -1; } int mr_path_is_image(const char *name) { if (!name) return 0; const char *dot = strrchr(name, '.'); if (!dot || dot == name) return 0; dot++; return strcasecmp(dot, "png") == 0 || strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0; } int mr_path_is_archive(const char *name) { if (!name) return 0; const char *dot = strrchr(name, '.'); if (!dot || dot == name) return 0; dot++; return strcasecmp(dot, "cbz") == 0 || strcasecmp(dot, "zip") == 0; } int mr_natural_cmp(const char *a, const char *b) { const unsigned char *pa = (const unsigned char *)a; const unsigned char *pb = (const unsigned char *)b; if (!a || !b) return (a == b) ? 0 : (a ? 1 : -1); while (*pa && *pb) { if (isdigit(*pa) && isdigit(*pb)) { const unsigned char *za = pa, *zb = pb; while (*pa == '0') pa++; while (*pb == '0') pb++; const unsigned char *da = pa, *db = pb; while (isdigit(*pa)) pa++; while (isdigit(*pb)) pb++; size_t la = (size_t)(pa - da), lb = (size_t)(pb - db); if (la != lb) return la < lb ? -1 : 1; if (la > 0) { int c = memcmp(da, db, la); if (c != 0) return c < 0 ? -1 : 1; } size_t oa = (size_t)(da - za), ob = (size_t)(db - zb); if (oa != ob) return oa < ob ? -1 : 1; continue; } int ca = tolower(*pa), cb = tolower(*pb); if (ca != cb) return ca < cb ? -1 : 1; pa++; pb++; } if (*pa) return 1; if (*pb) return -1; return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); } /* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */ static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) { if (!probe || !path || !path[0]) return 0; int n = probe(path, ud); return n > 0 ? n : 0; } /* ============================================================ */ /* 单层探测 + mtime/size 失效缓存 */ /* ============================================================ */ int mr_library_probe_dir(const char *dir, mr_library_probe *out) { if (!out) return -1; memset(out, 0, sizeof *out); if (!dir || !dir[0]) return -1; IO_OPEN(); DIR *d = opendir(dir); if (!d) return -1; char best[MR_NAME_MAX]; best[0] = '\0'; out->empty = 1; struct dirent *de; while ((de = readdir(d)) != NULL) { IO_READDIR(); const char *name = de->d_name; if (name[0] == '.') continue; /* 隐藏项不算内容 */ out->empty = 0; if (!mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; out->pages++; if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) { copy_str(best, (int)sizeof best, name); copy_str(out->cover, (int)sizeof out->cover, full); } } closedir(d); return 0; } #define MR_PROBE_CACHE_MAX 256 typedef struct probe_cache_entry { char path[MR_PATH_MAX]; long long mtime_ns; long long size; mr_library_probe probe; unsigned long long last_use; } probe_cache_entry; static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX]; static unsigned long long g_cache_clock; static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER; static long long stat_mtime_ns(const struct stat *st) { #if defined(__APPLE__) return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec; #else return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec; #endif } static probe_cache_entry *cache_find_locked(const char *path) { for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { if (!g_cache[i]) continue; if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i]; } return NULL; } static void cache_put_locked(const char *path, long long mtime_ns, long long size, const mr_library_probe *p) { probe_cache_entry *e = cache_find_locked(path); if (!e) { int slot = -1; for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) if (!g_cache[i]) { slot = i; break; } if (slot < 0) { /* 满:淘汰最久未用项 */ int lru = 0; unsigned long long oldest = g_cache[0]->last_use; for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) { if (g_cache[i]->last_use < oldest) { oldest = g_cache[i]->last_use; lru = i; } } free(g_cache[lru]); g_cache[lru] = NULL; slot = lru; } e = (probe_cache_entry *)malloc(sizeof *e); if (!e) return; /* 缓存只是加速:失败就每次现算 */ if (copy_str(e->path, (int)sizeof e->path, path) != 0) { free(e); return; } g_cache[slot] = e; } e->mtime_ns = mtime_ns; e->size = size; e->probe = *p; e->last_use = ++g_cache_clock; } void mr_library_probe_cache_clear(void) { pthread_mutex_lock(&g_cache_lock); for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { free(g_cache[i]); g_cache[i] = NULL; } g_cache_clock = 0; pthread_mutex_unlock(&g_cache_lock); } /* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */ static int probe_path_uncached(const char *path, int is_archive, mr_lib_archive_fn archive_pages, void *ud, mr_library_probe *out) { struct stat st; if (stat(path, &st) != 0) return -1; if (is_archive) { if (!archive_pages || !S_ISREG(st.st_mode)) return -1; out->pages = archive_pages_at(archive_pages, ud, path); out->empty = 0; out->cover[0] = '\0'; return 0; } if (!S_ISDIR(st.st_mode)) return -1; return mr_library_probe_dir(path, out); } int mr_library_probe_path(const char *path, int is_archive, mr_lib_archive_fn archive_pages, void *ud, mr_library_probe *out) { if (!out) return -1; memset(out, 0, sizeof *out); if (!path || !path[0]) return -1; struct stat st; if (stat(path, &st) != 0) return -1; if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1; long long mtime_ns = stat_mtime_ns(&st); long long size = (long long)st.st_size; pthread_mutex_lock(&g_cache_lock); probe_cache_entry *hit = cache_find_locked(path); if (hit && hit->mtime_ns == mtime_ns && hit->size == size) { *out = hit->probe; hit->last_use = ++g_cache_clock; pthread_mutex_unlock(&g_cache_lock); return 0; } pthread_mutex_unlock(&g_cache_lock); mr_library_probe p; memset(&p, 0, sizeof p); if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1; /* 评审 P2:只把「探测前后完全一致」的结果写入缓存。 * 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整, * 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */ struct stat st2; if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns && (long long)st2.st_size == size) { pthread_mutex_lock(&g_cache_lock); cache_put_locked(path, mtime_ns, size, &p); pthread_mutex_unlock(&g_cache_lock); } *out = p; return 0; } int mr_library_probe_cached(const char *dir, mr_library_probe *out) { return mr_library_probe_path(dir, 0, NULL, NULL, out); } /* ============================================================ */ /* 行状态:副标题 / 补位 / 行合成 */ /* ============================================================ */ /* 基础副标题(不含 M7 续读角标)。 */ static void entry_subtitle(mr_library_entry *e) { switch (e->probe_state) { case MR_LIB_READY: if (e->pages > 0) snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, e->pages == 1 ? "" : "s"); else if (!e->is_archive && e->empty) snprintf(e->subtitle, sizeof e->subtitle, "空目录"); else snprintf(e->subtitle, sizeof e->subtitle, "无图片"); break; case MR_LIB_FAILED: snprintf(e->subtitle, sizeof e->subtitle, "无法读取"); break; default: snprintf(e->subtitle, sizeof e->subtitle, "…"); break; } } /* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。 * 档案册以档案路径为册标识,同一套逻辑复用。 * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ static void entry_badge(mr_library_entry *e) { if (e->has_images != 1) return; char badge[24]; if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; int used = (int)strlen(e->subtitle); if (used < (int)sizeof e->subtitle - 2) snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); } void mr_library_format_subtitle(mr_library_entry *e) { if (!e) return; entry_subtitle(e); entry_badge(e); } void mr_library_free(mr_library *lib) { if (!lib) return; free(lib->items); lib->items = NULL; lib->count = 0; lib->cap = 0; lib->truncated = 0; } static int lib_add(mr_library *lib, const mr_library_entry *e) { if (lib->count >= lib->cap) { int cap = lib->cap ? lib->cap * 2 : 16; mr_library_entry *ni = (mr_library_entry *)realloc(lib->items, (size_t)cap * sizeof *ni); if (!ni) return -1; lib->items = ni; lib->cap = cap; } lib->items[lib->count++] = *e; return 0; } static int entry_cmp(const void *pa, const void *pb) { const mr_library_entry *a = (const mr_library_entry *)pa; const mr_library_entry *b = (const mr_library_entry *)pb; return mr_natural_cmp(a->name, b->name); } /* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */ static int add_dir_entry(mr_library *lib, const char *name, const char *full) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; e.pages = -1; e.has_images = -1; e.probe_state = MR_LIB_PENDING; mr_library_format_subtitle(&e); return lib_add(lib, &e); } /* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。 * pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。 * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ static int add_archive_entry(mr_library *lib, const char *name, const char *full, int pages) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; e.is_archive = 1; if (pages < 0) { e.pages = -1; e.has_images = -1; e.probe_state = MR_LIB_PENDING; } else { e.pages = pages; e.has_images = (pages > 0) ? 1 : 0; e.probe_state = MR_LIB_READY; } mr_library_format_subtitle(&e); return lib_add(lib, &e); } /* ============================================================ */ /* 单层扫描 */ /* ============================================================ */ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, mr_lib_archive_fn archive_pages, void *ud) { (void)max_depth; /* 兼容旧签名:扫描不再递归 */ (void)ud; if (!lib) return -1; mr_library_free(lib); if (!dir || !dir[0]) return -1; IO_OPEN(); DIR *d = opendir(dir); if (!d) return -1; struct dirent *de; while ((de = readdir(d)) != NULL) { IO_READDIR(); const char *name = de->d_name; if (name[0] == '.') continue; /* . .. 与隐藏项 */ char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; int is_dir = S_ISDIR(st.st_mode); int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) && mr_path_is_archive(name)); if (!is_dir && !is_arch) continue; if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } int rc = is_dir ? add_dir_entry(lib, name, full) : add_archive_entry(lib, name, full, -1); if (rc != 0) { closedir(d); mr_library_free(lib); return -1; } } closedir(d); if (lib->count > 1) qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); return lib->count; } int mr_library_scan(mr_library *lib, const char *dir, int max_depth) { return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); } int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p) { if (!lib || !p || idx < 0 || idx >= lib->count) return 0; mr_library_entry *e = &lib->items[idx]; e->pages = p->pages; e->empty = p->empty; e->has_images = (p->pages > 0) ? 1 : 0; e->probe_state = MR_LIB_READY; copy_str(e->cover, (int)sizeof e->cover, p->cover); mr_library_format_subtitle(e); return 1; } int mr_library_mark_error(mr_library *lib, int idx) { if (!lib || idx < 0 || idx >= lib->count) return 0; mr_library_entry *e = &lib->items[idx]; e->pages = -1; e->empty = 0; e->has_images = 0; e->probe_state = MR_LIB_FAILED; e->cover[0] = '\0'; mr_library_format_subtitle(e); return 1; } int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { static char trunc_sub[48]; static int trunc_ready; if (!lib || !out) return 0; if (idx == lib->count && lib->truncated) { if (!trunc_ready) { snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); trunc_ready = 1; } memset(out, 0, sizeof *out); out->title = "已截断"; out->subtitle = trunc_sub; out->disabled = 1; out->index = -1; return 1; } if (idx < 0 || idx >= lib->count) return 0; const mr_library_entry *e = &lib->items[idx]; memset(out, 0, sizeof *out); out->title = e->name; out->subtitle = e->subtitle; out->is_book = (e->has_images == 1); out->is_archive = e->is_archive; out->index = idx; return 1; } int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; size_t n = strlen(path); while (n > 1 && path[n - 1] == '/') n--; if (n <= 1) { if (outsz < 2) return -1; out[0] = '/'; out[1] = '\0'; return 0; } while (n > 0 && path[n - 1] != '/') n--; while (n > 1 && path[n - 1] == '/') n--; if (n >= (size_t)outsz) return -1; memcpy(out, path, n); out[n] = '\0'; return 0; } /* ============================================================ */ /* 册间衔接(M7 / M11) */ /* ============================================================ */ int mr_library_dir_has_images(const char *dir) { if (!dir || !dir[0]) return 0; DIR *d = opendir(dir); if (!d) return 0; int found = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { const char *name = de->d_name; if (name[0] == '.' || !mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; struct stat st; if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; found = 1; break; } closedir(d); return found; } static int name_ptr_cmp(const void *pa, const void *pb) { const char *a = *(const char *const *)pa; const char *b = *(const char *const *)pb; return mr_natural_cmp(a, b); } /* 兄弟册判据(M11):目录=直接含图;普通文件=档案册且有图片页。 * 两者同列,交给 name_ptr_cmp 自然序混排。 */ static int sibling_is_book(const char *full, const struct stat *st, mr_lib_archive_fn probe, void *ud) { if (S_ISDIR(st->st_mode)) return mr_library_dir_has_images(full); if (S_ISREG(st->st_mode)) { const char *slash = strrchr(full, '/'); const char *name = slash ? slash + 1 : full; if (!mr_path_is_archive(name)) return 0; return archive_pages_at(probe, ud, full) > 0; } return 0; } int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud) { if (!out || outsz <= 0) return -1; out[0] = '\0'; if (!dir || !dir[0] || (delta != 1 && delta != -1)) return -1; char norm[MR_PATH_MAX]; size_t dl = strlen(dir); while (dl > 1 && dir[dl - 1] == '/') dl--; if (dl >= sizeof norm) return -1; memcpy(norm, dir, dl); norm[dl] = '\0'; const char *slash = strrchr(norm, '/'); if (!slash || !slash[1]) return -1; const char *cur = slash + 1; char parent[MR_PATH_MAX]; if (mr_path_parent(norm, parent, (int)sizeof parent) != 0) return -1; DIR *d = opendir(parent); if (!d) return 0; char **names = NULL; int n = 0, cap = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { const char *name = de->d_name; if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (ignored_dir_name(name)) continue; char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, parent, name) != 0) continue; struct stat st; if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; if (!sibling_is_book(full, &st, archive_pages, ud)) continue; if (n >= cap) { int nc = cap ? cap * 2 : 16; char **nn = (char **)realloc(names, (size_t)nc * sizeof *nn); if (!nn) break; names = nn; cap = nc; } size_t len = strlen(name); names[n] = (char *)malloc(len + 1); if (!names[n]) break; memcpy(names[n], name, len + 1); n++; } closedir(d); int rc = 0; if (n > 1) qsort(names, (size_t)n, sizeof *names, name_ptr_cmp); int idx = -1; for (int i = 0; i < n; i++) if (strcmp(names[i], cur) == 0) { idx = i; break; } int target = idx + delta; if (idx >= 0 && target >= 0 && target < n) { if (join_path(out, outsz, parent, names[target]) == 0) rc = 1; } for (int i = 0; i < n; i++) free(names[i]); free(names); return rc; } int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) { return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); }