/* logic/appstate.c — 应用全局状态:ctx/登录/歌单/歌曲/设置。仅依赖核心库 + SDK meow_config_*。 */ #include "../ncm_app_internal.h" #include #include #include #include #define NCM_DEFAULT_DATA "/usr/data/mnt/sd_0/MeowData/dev.player.ncm/data" /* 滑动窗口互斥(REPORT §17):窗口读写(滑窗/物化/拷贝)全走此锁,见下方窗口节注释。 */ static pthread_mutex_t g_song_mu = PTHREAD_MUTEX_INITIALIZER; /* 搜索结果独立数组互斥(§23):g_st.search_songs/search_pls 读写 + queue_src 读。 */ static pthread_mutex_t g_search_mu = PTHREAD_MUTEX_INITIALIZER; /* 歌单加载上下文(§26 开A→未载完→开B 竞态防串):open_gen 在每次「打开/刷新歌单」时递增并记 * 目标下标;加载器(appstate_load_playlist_songs_ctx)在 worker 完成时校验代次/目标不匹配即 * 判过期(NCM_ERR_STALE),不把旧歌单的索引/窗口落进全局——防「上一歌单结果串进新打开的」。 * init 时 open_idx=-1(无打开目标),open_gen=0(无代次 → ctx_gen<0 兼容路径不校验)。 */ static pthread_mutex_t g_plopen_mu = PTHREAD_MUTEX_INITIALIZER; static int g_plopen_gen; /* 打开代次(每次 begin +1) */ static int g_plopen_idx = -1; /* 当前打开目标下标(-2=搜索哨兵;-1=无) */ int appstate_playlist_open_gen(void) { return g_plopen_gen; } int appstate_playlist_open_idx(void) { return g_plopen_idx; } int appstate_playlist_begin(int idx) { pthread_mutex_lock(&g_plopen_mu); g_plopen_gen++; g_plopen_idx = idx; int g = g_plopen_gen; pthread_mutex_unlock(&g_plopen_mu); return g; } /* 当前窗口归属歌单 id(§NEW bug B:新进歌单显示上一个歌单内容)。窗口数据(song_ids/songs/ * track_ids_count)属于哪个歌单由 id 唯一标识(对搜索歌单同样成立——不同搜索结果 = 不同 id)。 * worker 首屏发布窗口后调 appstate_playlist_window_set 标记;playlist_refresh 在起 worker 前用 * appstate_playlist_window_is 判「目标歌单是否=当前窗口归属」,不一致 → 同步 appstate_playlist_reset() * 清掉上一歌单残留,防 pl_apply(1) 把新歌单标题绑到上一歌单数据(「开 B 显示 A」)。 */ static pthread_mutex_t g_winpl_mu = PTHREAD_MUTEX_INITIALIZER; static char g_win_plid[32]; void appstate_playlist_window_set(const char *plid) { pthread_mutex_lock(&g_winpl_mu); if (plid && plid[0]) ncm_strlcpy(g_win_plid, plid, sizeof g_win_plid); else g_win_plid[0] = 0; pthread_mutex_unlock(&g_winpl_mu); } int appstate_playlist_window_is(const char *plid) { pthread_mutex_lock(&g_winpl_mu); int same = (plid && plid[0] && g_win_plid[0] && strcmp(g_win_plid, plid) == 0); pthread_mutex_unlock(&g_winpl_mu); return same; } /* 清窗口/索引(UI 丢弃过期歌单结果时用;播放快照不受影响——播放经 play_song 快照)。 */ void appstate_playlist_reset(void) { pthread_mutex_lock(&g_song_mu); memset(g_st.song_valid, 0, sizeof g_st.song_valid); memset(g_st.song_ids, 0, sizeof g_st.song_ids); g_st.window_base = 0; g_st.song_count = 0; g_st.track_ids_count = 0; pthread_mutex_unlock(&g_song_mu); pthread_mutex_lock(&g_winpl_mu); /* 窗口已清 → 归属歌单一并清空(下一 worker 重新标记) */ g_win_plid[0] = 0; pthread_mutex_unlock(&g_winpl_mu); } static void appstate_srch_hist_init(void); /* §23:读入本地搜索历史(appstate_init 首调) */ int appstate_init(void) { const char *dd = getenv("MEOW_DATA_DIR"); if (!dd || !dd[0]) dd = NCM_DEFAULT_DATA; strncpy(g_st.data_dir, dd, sizeof g_st.data_dir - 1); g_st.data_dir[sizeof g_st.data_dir - 1] = 0; ncm_ctx_init(&g_st.ctx, g_st.data_dir); /* device_id + cookie 载入 + cache */ if (g_st.ctx.cache.dir[0]) ncm_strlcpy(g_st.cache_dir, g_st.ctx.cache.dir, sizeof g_st.cache_dir); else ncm_ctx_default_cache_dir(g_st.data_dir, g_st.cache_dir, sizeof g_st.cache_dir); /* §NEW 缓存分目录一次性迁移:旧的 song_*、cov* 混放根 → songs/ 与 covers/(init 已搬完,此处只披露)。 */ if (g_st.ctx.cache.legacy_migrated > 0) meow_log(MEOW_LOG_INFO, "ncm: cache legacy migration moved %d file(s) into songs/ and covers/", g_st.ctx.cache.legacy_migrated); /* 风控加固 §1:接口级 JSON 响应短 TTL 缓存的盘层挂到 /ncm_api_cache/。 * (内存层由 core 初始化默认启用;盘层跨进程/热更保留,启动清一次过期盘文件。) */ ncm_apicache_init(g_st.data_dir); appstate_load_settings(); g_st.logged_in = (g_st.ctx.cookie[0] != 0); g_st.active_playlist = -1; g_st.playing_song = -1; g_st.qr_size = 0; g_st.login_state = NCM_LOGIN_PENDING; /* uid/nickname 从配置恢复(登录时落盘);uid 缺失时不在此阻塞校验——由 home_reload 拉歌单时 * appstate_ensure_uid 触发 login_status_check(启动快,避免首屏长时间网络阻塞)。 */ meow_config_get("ncm.uid", g_st.uid, sizeof g_st.uid); meow_config_get("ncm.nickname", g_st.nickname, sizeof g_st.nickname); account_load(); /* §20:恢复会员态缓存(account.json;无文件则 NCM_VIP_NONE) */ g_st.queue_src = NCM_QUEUE_PLAYLIST; /* §23:默认播放队列=歌单窗口 */ g_st.search_tab = 0; appstate_srch_hist_init(); /* §23:读取本地搜索历史 */ return 0; } void appstate_load_settings(void) { g_st.cache_limit_gb = meow_config_int("ncm.cache_limit_gb", 10); if (g_st.cache_limit_gb < 1) g_st.cache_limit_gb = 1; if (g_st.cache_limit_gb > 100) g_st.cache_limit_gb = 100; /* §20 默认档 = 极高 HQ(EXHIGH,用户定案 NCM_CACHE_DESIGN §3;旧默认 Hi-Res 改掉)。 * 已持久化的合法旧值(含 HIRES 等)原样保留;越界值回落默认档。 */ g_st.level = meow_config_int("ncm.level", NCM_LEVEL_EXHIGH); if (g_st.level < NCM_LEVEL_STANDARD || g_st.level > NCM_LEVEL_UI_MAX) g_st.level = NCM_LEVEL_EXHIGH; /* §22 歌词页背景模式:default(模糊置底) / power_save(封面+黑叠加层);越界回落 default */ g_st.player_bg_mode = meow_config_int("ncm.player_bg_mode", NCM_BG_DEFAULT); if (g_st.player_bg_mode != NCM_BG_DEFAULT && g_st.player_bg_mode != NCM_BG_POWER_SAVE) g_st.player_bg_mode = NCM_BG_DEFAULT; ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); g_st.ctx.default_level = g_st.level; } void appstate_save_settings(void) { char b[16]; snprintf(b, sizeof b, "%d", g_st.cache_limit_gb); meow_config_set("ncm.cache_limit_gb", b); snprintf(b, sizeof b, "%d", g_st.level); meow_config_set("ncm.level", b); snprintf(b, sizeof b, "%d", g_st.player_bg_mode); meow_config_set("ncm.player_bg_mode", b); ncm_cache_set_limit_gb(&g_st.ctx.cache, g_st.cache_limit_gb); g_st.ctx.default_level = g_st.level; /* 手动调低上限时立即按 LRU 淘汰(超上限删最旧);未超限则为无操作 */ ncm_cache_evict(&g_st.ctx.cache); } void appstate_set_logged_in(const char *uid, const char *nickname) { if (uid && uid[0]) { ncm_strlcpy(g_st.uid, uid, sizeof g_st.uid); meow_config_set("ncm.uid", uid); } if (nickname && nickname[0]) { ncm_strlcpy(g_st.nickname, nickname, sizeof g_st.nickname); meow_config_set("ncm.nickname", nickname); } g_st.logged_in = 1; } void appstate_logout(void) { ncm_ctx_clear_cookie(&g_st.ctx); /* 风控加固 §1:登出/换账号清接口级响应缓存(防上一账号缓存的 user/playlist/cloudsearch * 被下一账号复用;盘缓存目录一并清)。 */ ncm_apicache_clear(); g_st.logged_in = 0; g_st.uid[0] = 0; g_st.nickname[0] = 0; g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */ g_st.vip_expire_ms = 0; g_st.vip_checked_at = 0; { char ap[600]; snprintf(ap, sizeof ap, "%s/account.json", g_st.data_dir); remove(ap); } g_st.active_playlist = -1; g_st.playing_song = -1; pthread_mutex_lock(&g_song_mu); memset(g_st.song_valid, 0, sizeof g_st.song_valid); g_st.window_base = 0; g_st.song_count = 0; g_st.track_ids_count = 0; pthread_mutex_unlock(&g_song_mu); meow_config_remove("ncm.uid"); meow_config_remove("ncm.nickname"); cover_cache_reset(); } /* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。 */ int appstate_ensure_uid(void) { if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH; if (g_st.uid[0]) return NCM_OK; char uid[32], nick[128]; int rc = ncm_login_status_check(g_st.ctx.device_id, g_st.ctx.cookie, uid, sizeof uid, nick, sizeof nick); if (rc == NCM_OK) { appstate_set_logged_in(uid, nick); return NCM_OK; } /* 会话失效:清 cookie → 未登录 */ if (rc == NCM_ERR_AUTH) { ncm_ctx_clear_cookie(&g_st.ctx); g_st.logged_in = 0; } return rc; } /* 歌单列表 staging 发布锁(§NEW bug A):appstate_load_playlists 的「网络成功后锁内原子发布 * (拷回 g_st.playlists[] + 更新 playlist_count)」与 UI 线程 home_src2/home_draw 无锁逐行绑定 * 之间的发布点。持久化(playlists.bin)同单写者(home worker)在发布后读,无相互竞态。 */ static pthread_mutex_t g_pls_mu = PTHREAD_MUTEX_INITIALIZER; int appstate_load_playlists(void) { if (!g_st.logged_in) return NCM_ERR_AUTH; int rc = appstate_ensure_uid(); if (rc != NCM_OK) return rc; /* §NEW bug A(返回首页各行 2~3 秒空白):旧实现在**UI 可见数组**就地 memset→网络回填,与 * home_src2 无锁逐行绑定竞态——刷新期间 playlist_count 保留旧值(cache),UI 读到被清零的行 * (无标题/曲目数 0),仅下标 0 因先读幸存。照搬本文件 appstate_load_playlist_songs_impl 的 * s_build[] staging 范式:网络结果落 staging(s_pls[]),成功后锁内**一次性拷回** g_st.playlists[] * + 更新 playlist_count(原子发布——UI 全程读到旧或新完整快照,绝不读到清零态);失败保留旧 * 快照(不改 playlists/playlist_count,断网可离线浏览旧列表)。静态数组:单写者(home worker * 单飞),免 96KB 栈。 */ static ncm_playlist_t s_pls[NCM_PLAYLIST_MAX]; int n = 0; int rc2 = ncm_api_user_playlist(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid, NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n); if (rc2 == NCM_OK) { int cnt = (n > NCM_PLAYLIST_MAX) ? NCM_PLAYLIST_MAX : n; pthread_mutex_lock(&g_pls_mu); if (cnt > 0) memcpy(g_st.playlists, s_pls, (size_t)cnt * sizeof(ncm_playlist_t)); g_st.playlist_count = cnt; /* count 在拷回后更新:读侧绝不见「新 count + 未拷回行」 */ pthread_mutex_unlock(&g_pls_mu); appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */ } meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s", rc2, (rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid); return rc2; } /* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)---- * 快照 /playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。 * 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。 * 刻意不进 ncm_cache LRU 池(那是音频/封面池,被「清理缓存」清空)——歌单列表是浏览数据。 */ #define PLS_CACHE_MAGIC 0x50534C54u /* 'PLST' */ #define PLS_CACHE_VER 1 static int64_t g_pls_cache_ts; /* 上次缓存快照时间(秒) */ int appstate_playlists_cache_save(void) { char path[640]; snprintf(path, sizeof path, "%s/playlists.bin", g_st.data_dir); FILE *f = fopen(path, "wb"); if (!f) return NCM_ERR_IO; int64_t ts = ncm_now_ms() / 1000; uint32_t magic = PLS_CACHE_MAGIC; uint16_t ver = PLS_CACHE_VER; uint32_t count = (uint32_t)g_st.playlist_count; if (count > NCM_PLAYLIST_MAX) count = NCM_PLAYLIST_MAX; int ok = (fwrite(&magic, sizeof magic, 1, f) == 1 && fwrite(&ver, sizeof ver, 1, f) == 1 && fwrite(&ts, sizeof ts, 1, f) == 1 && fwrite(&count, sizeof count, 1, f) == 1 && fwrite(g_st.playlists, sizeof(ncm_playlist_t), (size_t)count, f) == (size_t)count); fclose(f); if (ok) { g_pls_cache_ts = ts; return NCM_OK; } unlink(path); return NCM_ERR_IO; } int appstate_playlists_cache_load(int64_t *ts_out) { if (ts_out) *ts_out = 0; if (g_st.logged_in == 0) return NCM_ERR_AUTH; /* 未登录不落缓存 */ char path[640]; snprintf(path, sizeof path, "%s/playlists.bin", g_st.data_dir); FILE *f = fopen(path, "rb"); if (!f) return NCM_ERR_NOENT; uint32_t magic; uint16_t ver; int64_t ts; uint32_t count; int ok = (fread(&magic, sizeof magic, 1, f) == 1 && fread(&ver, sizeof ver, 1, f) == 1 && fread(&ts, sizeof ts, 1, f) == 1 && fread(&count, sizeof count, 1, f) == 1 && magic == PLS_CACHE_MAGIC && ver == PLS_CACHE_VER && count <= NCM_PLAYLIST_MAX); if (!ok) { fclose(f); return NCM_ERR_IO; } size_t rd = fread(g_st.playlists, sizeof(ncm_playlist_t), (size_t)count, f); fclose(f); if (rd != (size_t)count) return NCM_ERR_IO; g_st.playlist_count = (int)count; g_pls_cache_ts = ts; if (ts_out) *ts_out = ts; meow_log(MEOW_LOG_INFO, "home: playlists cache hit count=%d ts=%lld", (int)count, (long long)ts); return NCM_OK; } /* ============================================================ * 滑动窗口(REPORT §17,三期第三批) * songs[i] ↔ 全局下标 window_base+i;song_valid[] 为槽位有效位图。 * 写侧(滑窗/物化)与读侧(song_copy)全走 g_song_mu;网络/盘 IO 在锁外做, * 锁内只做内存搬移,UI 行回调(song_copy)不被网络阻塞。 * 单写者假设:ensure/page 仅由 playlist worker(及兼容入口)驱动,同一时刻最多一个。 * ============================================================ */ static int slot_valid_locked(int gidx) { int s = gidx - g_st.window_base; return s >= 0 && s < NCM_SONG_WINDOW && g_st.song_valid[s]; } static void win_recount_locked(void) { int n = 0; for (int i = 0; i < NCM_SONG_WINDOW; i++) if (g_st.song_valid[i]) n++; g_st.song_count = n; } /* 锁内滑窗:重叠区 songs+valid 一起 memmove 保留,非重叠端清零(出窗槽位失效)。 * 出窗歌曲的元数据此前已落盘(songmeta),丢弃无数据损失。 */ static void win_slide_locked(int new_base) { int old_base = g_st.window_base; if (new_base == old_base) return; int ov_lo = (old_base > new_base) ? old_base : new_base; int ov_hi = (old_base + NCM_SONG_WINDOW < new_base + NCM_SONG_WINDOW) ? old_base + NCM_SONG_WINDOW : new_base + NCM_SONG_WINDOW; if (ov_lo < ov_hi) { int src = ov_lo - old_base, dst = ov_lo - new_base, n = ov_hi - ov_lo; memmove(&g_st.songs[dst], &g_st.songs[src], (size_t)n * sizeof(ncm_song_t)); memmove(&g_st.song_valid[dst], &g_st.song_valid[src], (size_t)n); if (dst > 0) memset(g_st.song_valid, 0, (size_t)dst); if (dst + n < NCM_SONG_WINDOW) memset(&g_st.song_valid[dst + n], 0, (size_t)(NCM_SONG_WINDOW - dst - n)); } else { memset(g_st.song_valid, 0, sizeof g_st.song_valid); } g_st.window_base = new_base; win_recount_locked(); } int appstate_range_loaded(int lo, int hi) { if (lo < 0) lo = 0; if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; if (lo >= hi) return 1; pthread_mutex_lock(&g_song_mu); int ok = 1; for (int i = lo; i < hi; i++) if (!slot_valid_locked(i)) { ok = 0; break; } pthread_mutex_unlock(&g_song_mu); return ok; } int appstate_song_copy(int gidx, ncm_song_t *out) { if (!out) return -1; pthread_mutex_lock(&g_song_mu); int s = gidx - g_st.window_base; int ok = (s >= 0 && s < NCM_SONG_WINDOW && g_st.song_valid[s]); if (ok) *out = g_st.songs[s]; pthread_mutex_unlock(&g_song_mu); return ok ? 0 : -1; } int appstate_song_ensure_range(int lo, int hi) { if (lo < 0) lo = 0; if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; if (lo >= hi) return NCM_OK; /* 目标基址:请求区间居中(前后留余量,滚动反向时不立即滑窗),钳制到合法范围。 */ int base; if (hi - lo <= NCM_SONG_WINDOW) { base = (lo + hi) / 2 - NCM_SONG_WINDOW / 2; if (base > lo) base = lo; if (base + NCM_SONG_WINDOW < hi) base = hi - NCM_SONG_WINDOW; int maxb = g_st.track_ids_count - NCM_SONG_WINDOW; if (maxb < 0) maxb = 0; if (base > maxb) base = maxb; if (base < 0) base = 0; } else { base = lo; /* 请求区间超窗(不应发生):覆盖前缀 */ } pthread_mutex_lock(&g_song_mu); int covered = 1; for (int i = lo; i < hi; i++) if (!slot_valid_locked(i)) { covered = 0; break; } if (!covered && base != g_st.window_base) win_slide_locked(base); else base = g_st.window_base; /* 已覆盖:维持原基址 */ pthread_mutex_unlock(&g_song_mu); if (covered) return NCM_OK; /* 逐段补齐 [lo,hi) 内 invalid 槽:先试盘缓存(songmeta),miss 攒批 ≤NCM_SONG_BATCH * 走 song_detail;成功写窗 + 落盘。网络/盘 IO 均在锁外,锁内仅写槽。 */ static ncm_song_t s_fetch[NCM_SONG_BATCH]; /* 单写者(playlist worker),免 60KB 栈 */ int rc = NCM_OK; int first_err = NCM_OK; /* §26:批内错误非致命(缺歌/单批网络失败),记首个供 UI 提示 */ for (int a = lo; a < hi; ) { pthread_mutex_lock(&g_song_mu); int va = slot_valid_locked(a); pthread_mutex_unlock(&g_song_mu); if (va) { a++; continue; } int b = a + 1; while (b < hi && b - a < NCM_SONG_BATCH) { pthread_mutex_lock(&g_song_mu); int vb = slot_valid_locked(b); pthread_mutex_unlock(&g_song_mu); if (vb) break; b++; } /* 批网格对齐预取:把 [a,b) 扩到 song_detail 批边界(§17.4——逐行滚动时若按原始 * 小区间拉取,song_detail 调用次数会放大到 ~歌单长度/滚动步长;对齐到 100 首 * 网格后每次补齐整批,总请求数 ≈ 歌单长度/NCM_SONG_BATCH)。 */ int a0 = (a / NCM_SONG_BATCH) * NCM_SONG_BATCH; int b0 = ((b + NCM_SONG_BATCH - 1) / NCM_SONG_BATCH) * NCM_SONG_BATCH; int wend2 = g_st.window_base + NCM_SONG_WINDOW; if (wend2 > g_st.track_ids_count) wend2 = g_st.track_ids_count; if (a0 < g_st.window_base) a0 = g_st.window_base; if (b0 > wend2) b0 = wend2; if (b0 - a0 > NCM_SONG_BATCH) b0 = a0 + NCM_SONG_BATCH; /* 本段 [a0,b0):盘命中直接写窗,有效槽跳过,miss 记录待批拉。 * 索引读取在锁内(与「索引重建」写 song_ids 的 worker/重建路径防竞态)。 */ int64_t ids[NCM_SONG_BATCH]; int gslot[NCM_SONG_BATCH]; int m = 0; for (int i = a0; i < b0; i++) { pthread_mutex_lock(&g_song_mu); int vv = slot_valid_locked(i); int64_t sid64 = vv ? 0 : g_st.song_ids[i]; pthread_mutex_unlock(&g_song_mu); if (vv) continue; char sid[24]; snprintf(sid, sizeof sid, "%lld", (long long)sid64); ncm_song_t ms; if (songmeta_load(g_st.cache_dir, sid, &ms) == NCM_OK) { pthread_mutex_lock(&g_song_mu); int s = i - g_st.window_base; if (s >= 0 && s < NCM_SONG_WINDOW) { g_st.songs[s] = ms; g_st.song_valid[s] = 1; } pthread_mutex_unlock(&g_song_mu); } else { ids[m] = sid64; gslot[m] = i; m++; } } if (m > 0) { int bn = 0; int r2 = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, ids, m, s_fetch, NCM_SONG_BATCH, &bn); if (r2 == NCM_OK) { if (bn > m) bn = m; for (int k = 0; k < bn; k++) { /* song_detail 结果与请求 ids 同序 */ pthread_mutex_lock(&g_song_mu); int s = gslot[k] - g_st.window_base; if (s >= 0 && s < NCM_SONG_WINDOW) { g_st.songs[s] = s_fetch[k]; g_st.song_valid[s] = 1; } pthread_mutex_unlock(&g_song_mu); char sid[24]; snprintf(sid, sizeof sid, "%lld", (long long)ids[k]); songmeta_save(g_st.cache_dir, sid, &s_fetch[k]); } /* §26:部分返回(bn a) ? b0 : b; /* 推进到本批网格末尾:失败批不在同一次调用内被重复重发 */ } pthread_mutex_lock(&g_song_mu); win_recount_locked(); pthread_mutex_unlock(&g_song_mu); return (first_err != NCM_OK) ? first_err : rc; } int appstate_song_fetch(int gidx, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; if (gidx < 0) return NCM_ERR_ARG; pthread_mutex_lock(&g_song_mu); if (gidx >= g_st.track_ids_count) { pthread_mutex_unlock(&g_song_mu); return NCM_ERR_ARG; } int64_t id = g_st.song_ids[gidx]; pthread_mutex_unlock(&g_song_mu); if (id == 0) return NCM_ERR_ARG; /* 无索引(如搜索上下文窗外) */ char sid[24]; snprintf(sid, sizeof sid, "%lld", (long long)id); if (songmeta_load(g_st.cache_dir, sid, out) == NCM_OK) return NCM_OK; /* 盘缓存 */ int bn = 0; int rc = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, &id, 1, out, 1, &bn); if (rc != NCM_OK) return rc; if (bn < 1) return NCM_ERR_NOTFOUND; songmeta_save(g_st.cache_dir, sid, out); return NCM_OK; } void appstate_adopt_songs(int n) { pthread_mutex_lock(&g_song_mu); if (n < 0) n = 0; if (n > NCM_SONG_WINDOW) n = NCM_SONG_WINDOW; memset(g_st.song_valid, 0, sizeof g_st.song_valid); for (int i = 0; i < n; i++) { g_st.song_valid[i] = 1; g_st.song_ids[i] = strtoll(g_st.songs[i].id, NULL, 10); } g_st.window_base = 0; g_st.song_count = n; g_st.track_ids_count = n; pthread_mutex_unlock(&g_song_mu); } /* 核心加载(无代次校验):pl 为打开时的快照副本(已拷贝,之后不读 g_st.playlists[]—— * 防 home_worker 刷新歌单列表 memset 旧数组时把正在加载的歌单 id 污染成空、导致 * detail("") 取回 3 首歌/串数据/加载失败,§26)。offset==0 重头用 staging 缓冲收编 * detail 结果、锁内原子发布索引(防 worker 写 song_ids 与播放线程 song_fetch 读竞态)。 */ static int appstate_load_playlist_songs_impl(const ncm_playlist_t *pl, int is_search, int offset, int limit) { (void)is_search; if (limit <= 0 || limit > NCM_SONG_BATCH) limit = NCM_SONG_BATCH; if (offset < 0) offset = 0; /* REPORT §13.1:/api/playlist/track/all 服务端分页端点真机全 404(NCM 无此路由),弃用。 * §14 的分页(append-only 到 NCM_SONG_MAX=1000 截断)在 §17 升级为滑动窗口: * detail(n=1)仍取【全量】trackIds 入 song_ids[](≤ NCM_TRACKID_MAX,支撑 6000+ 首), * 歌曲对象只物化视口附近的 NCM_SONG_WINDOW 首(offset 为「要确保的区间」起点)。 */ if (offset == 0) { /* 重头:清窗口 + 重建 trackIds 索引(song_count/track_ids_count 无论成败都归零)。 * 封面兜底值取自快照 pl->cover(不读 g_st.playlists[],防污染)。 */ pthread_mutex_lock(&g_song_mu); memset(g_st.song_valid, 0, sizeof g_st.song_valid); memset(g_st.song_ids, 0, sizeof g_st.song_ids); g_st.window_base = 0; g_st.song_count = 0; g_st.track_ids_count = 0; pthread_mutex_unlock(&g_song_mu); ncm_strlcpy(g_st.cover_url, pl->cover, sizeof g_st.cover_url); /* detail 为网络调用(不能持锁);结果落 staging 缓冲,锁内一次性发布索引(原子)。 * 静态缓冲:本加载函数单写者(playlist worker 单飞),免 128KB 栈。 */ static int64_t s_build[NCM_TRACKID_MAX]; int idn = 0; int rc = ncm_api_playlist_detail(g_st.ctx.device_id, g_st.ctx.cookie, pl->id, NULL, 0, NULL, 0, s_build, NCM_TRACKID_MAX, &idn); if (rc != NCM_OK) return rc; if (idn > NCM_TRACKID_MAX) idn = NCM_TRACKID_MAX; pthread_mutex_lock(&g_song_mu); if (idn > 0) memcpy(g_st.song_ids, s_build, (size_t)idn * sizeof(int64_t)); g_st.track_ids_count = idn; pthread_mutex_unlock(&g_song_mu); } else if (g_st.track_ids_count == 0) { /* 防御:offset>0 但没有索引(不应发生;主路径先走 offset=0)。 */ return NCM_ERR_ARG; } if (offset >= g_st.track_ids_count) return NCM_OK; /* 已到尾部 */ int hi = offset + limit; if (hi > g_st.track_ids_count) hi = g_st.track_ids_count; return appstate_song_ensure_range(offset, hi); } int appstate_load_playlist_songs_ctx(const ncm_playlist_t *pl, int is_search, int ctx_gen, int ctx_idx, int offset, int limit) { if (!pl || !pl->id[0]) return NCM_ERR_ARG; /* §26 打开上下文校验:代次/目标不匹配 → 判过期(不写全局),防「开 A→开 B 的迟到结果串 B」。 * ctx_gen<0 = 兼容调用方(既有单测/autoplay)不校验,永远采纳。 */ if (ctx_gen >= 0 && (ctx_gen != g_plopen_gen || ctx_idx != g_plopen_idx)) return NCM_ERR_STALE; int rc = appstate_load_playlist_songs_impl(pl, is_search, offset, limit); /* 加载跨网络(detail 一次 + song_detail 分批),期间用户可能已切到新歌单(开 B): * 再验一次代次,过期即弃(清窗口,避免旧歌单数据被画在新歌单标题下)。 */ if (ctx_gen >= 0 && (ctx_gen != g_plopen_gen || ctx_idx != g_plopen_idx)) { appstate_playlist_reset(); return NCM_ERR_STALE; } return rc; } int appstate_load_playlist_songs_page(int idx, int offset, int limit) { /* 搜索歌单详情(§23):idx==NCM_PL_SEARCH 时读 g_st.search_pl_open(复用本链路)。 */ if (idx != NCM_PL_SEARCH && (idx < 0 || idx >= g_st.playlist_count)) return NCM_ERR_ARG; /* 不在此处写 g_st.active_playlist:调用方(playlist_open/UI)已在 UI 线程设定; * 若后台 worker 在此改写,会覆盖用户已切换到的新歌单,造成“打开 B 却显示 A”。 */ ncm_playlist_t snap; int is_search = (idx == NCM_PL_SEARCH); if (is_search) snap = g_st.search_pl_open; else snap = g_st.playlists[idx]; /* ctx_gen=-1:兼容入口(单测/autoplay)不做代次校验——由调用方协调并发。 */ return appstate_load_playlist_songs_ctx(&snap, is_search, -1, idx, offset, limit); } int appstate_load_playlist_songs(int idx) { /* 兼容入口:载索引 + 物化首个窗口。 */ int rc = appstate_load_playlist_songs_page(idx, 0, NCM_SONG_BATCH); if (rc != NCM_OK) return rc; int hi = (g_st.track_ids_count < NCM_SONG_WINDOW) ? g_st.track_ids_count : NCM_SONG_WINDOW; return appstate_song_ensure_range(0, hi); } void appstate_apply_playlist_cover(int idx) { if (idx == NCM_PL_SEARCH) { ncm_strlcpy(g_st.cover_url, g_st.search_pl_open.cover, sizeof g_st.cover_url); return; } if (idx < 0 || idx >= g_st.playlist_count) return; ncm_strlcpy(g_st.cover_url, g_st.playlists[idx].cover, sizeof g_st.cover_url); } /* 设置播放页当前歌曲下标。cover_url 非空时一并更新 g_st.cover_url(歌单封面兜底值); * §18 起调用方传 NULL——兜底值由 appstate_apply_playlist_cover 维护,勿用单曲 al_pic 污染。 */ void appstate_set_playing_song(int idx, const char *cover_url) { g_st.playing_song = idx; if (cover_url && cover_url[0]) ncm_strlcpy(g_st.cover_url, cover_url, sizeof g_st.cover_url); } int appstate_current_playing(void) { if (g_st.playing_song < 0) return -1; int cnt = appstate_queue_count(); /* §23:搜索队列时按搜索条数界(不误判歌单亮点) */ if (g_st.playing_song >= cnt) return -1; return g_st.playing_song; } /* ---- 真机联调 autoplay hook(A2 音频迁移验收用,非正式能力)---- * 触发文件 /tmp/ncm_autoplay 存在且 home 歌单已载 → 后台线程找「已缓存」曲目并播放: * - 内容为纯数字 → 从该歌单下标起扫最多 AUTOPLAY_SCAN_PL 个歌单首页; * - 内容为其它文本 → 按歌单名子串匹配**单个**歌单(如 "Docu";避开超大歌单的 * 全量 trackIds 解析,真机联调省时省内存); * - 空文件 → 从 0 起扫。 * 风控纪律:**绝不发 song_url 下载**——无缓存命中只记日志退出;metadata 请求 = 每歌单 * 1 detail + 1 song_detail(≤ 2×AUTOPLAY_SCAN_PL 次)。 */ #define AUTOPLAY_TRIGGER "/tmp/ncm_autoplay" #define AUTOPLAY_SCAN_PL 3 /* 真机崩溃复现扩展:autoplay 在后台线程起播后,把「打开播放页」动作交给 UI 线程 * (meow_view_page_push/view_enter 非线程安全,UI 栈只能 UI 线程驱动)。worker 写 * g_autoplay_* 标志,app_on_tick 每 tick 用 autoplay_ui_consume() 取走并调 player_play_song, * 从而完整复现用户「点曲 → 播放页渲染(封面/歌词/动画/进度)」路径。仅 /tmp/ncm_autoplay 存在时激活。 */ static volatile int g_autoplay_goto_player = 0; static int g_autoplay_play_idx = -1; int autoplay_ui_consume(void) /* UI 线程取走「已缓存起播」标志,返回曲目 idx(无则 -1) */ { if (!g_autoplay_goto_player) return -1; g_autoplay_goto_player = 0; int i = g_autoplay_play_idx; g_autoplay_play_idx = -1; return i; } /* 播放单个歌单首页中第一首已缓存曲目。命中播放返回 1,否则 0。 */ static int autoplay_try_playlist(int pl) { int rc = appstate_load_playlist_songs_page(pl, 0, NCM_SONG_BATCH); if (rc != NCM_OK) { meow_log(MEOW_LOG_WARN, "autoplay: playlist[%d] load rc=%d", pl, rc); return 0; } char cpath[600]; for (int i = 0; i < g_st.track_ids_count; i++) { /* 滑窗模型:锁内拷贝窗内有效槽 */ ncm_song_t s; if (appstate_song_copy(i, &s) != 0) break; /* 出窗即停(首页之后未物化) */ /* §19 缓存键 = id+档位(§20 起用 level_key:sky 与 sky_c51 可区分): * 先试当前档位键(flac/mp3),再回退旧无档位键(存量 mp3) */ char key[64]; snprintf(key, sizeof key, "%s_%s", s.id, ncm_api_level_key(g_st.level)); if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", cpath, sizeof cpath) == NCM_OK || ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", cpath, sizeof cpath) == NCM_OK || ncm_cache_lookup(&g_st.ctx.cache, s.id, "mp3", cpath, sizeof cpath) == NCM_OK) { g_st.active_playlist = pl; meow_log(MEOW_LOG_INFO, "autoplay: playlist[%d] song[%d] id=%s cached -> play", pl, i, s.id); /* 交给 UI 线程打开播放页(模拟用户点曲播放),避免后台线程调 UI 栈 */ g_autoplay_play_idx = i; g_autoplay_goto_player = 1; return 1; } } meow_log(MEOW_LOG_INFO, "autoplay: playlist[%d] first window %d/%d songs, no cached track", pl, g_st.song_count, g_st.track_ids_count); return 0; } static void *autoplay_worker(void *arg) { const char *sel = (const char *)arg; /* NULL=从 0 起扫;数字=起始下标;其它=歌单名子串 */ if (sel && sel[0] && !(sel[0] >= '0' && sel[0] <= '9')) { for (int pl = 0; pl < g_st.playlist_count && !g_app_quit; pl++) { if (strstr(g_st.playlists[pl].name, sel)) { meow_log(MEOW_LOG_INFO, "autoplay: name '%s' -> playlist[%d] '%s' (%d tracks)", sel, pl, g_st.playlists[pl].name, g_st.playlists[pl].track_count); if (autoplay_try_playlist(pl)) { free((void *)sel); return NULL; } break; } } meow_log(MEOW_LOG_WARN, "autoplay: no playlist name match / no cached track ('%s')", sel); free((void *)sel); return NULL; } int start = (sel && sel[0]) ? atoi(sel) : 0; free((void *)sel); if (start < 0) start = 0; int end = start + AUTOPLAY_SCAN_PL; if (end > g_st.playlist_count) end = g_st.playlist_count; for (int pl = start; pl < end && !g_app_quit; pl++) if (autoplay_try_playlist(pl)) return NULL; meow_log(MEOW_LOG_WARN, "autoplay: no cached track found (no download, risk control)"); return NULL; } void autoplay_maybe(void) { static int s_fired = 0; if (s_fired || g_app_quit) return; if (access(AUTOPLAY_TRIGGER, F_OK) != 0) return; if (!g_st.logged_in || g_st.playlist_count <= 0) return; /* 等 home 歌单载完 */ s_fired = 1; char buf[64] = ""; FILE *f = fopen(AUTOPLAY_TRIGGER, "r"); /* 一次性:读完即删 */ if (f) { if (fgets(buf, sizeof buf, f)) { size_t n = strlen(buf); while (n && (buf[n - 1] == '\n' || buf[n - 1] == '\r' || buf[n - 1] == ' ')) buf[--n] = 0; } fclose(f); } unlink(AUTOPLAY_TRIGGER); meow_log(MEOW_LOG_INFO, "autoplay: triggered, sel='%s'", buf); char *sel = strdup(buf); pthread_t t; if (ncm_thread_create(&t, autoplay_worker, sel) == 0) pthread_detach(t); else free(sel); } /* ============================================================ * 搜索(REPORT §23):独立结果数组(不覆写歌单窗口)+ 播放队列来源 + 本地历史。 * 结果数组读写经 g_search_mu;queue_src 决定超时/起播读数组还是窗口。 * ============================================================ */ void appstate_set_queue(int src) { g_st.queue_src = src; } int appstate_get_queue(void) { return g_st.queue_src; } int appstate_queue_count(void) { int c; 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); return c < 0 ? 0 : c; } int appstate_queue_song(int idx, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; if (g_st.queue_src == NCM_QUEUE_SEARCH) { pthread_mutex_lock(&g_search_mu); int ok = (idx >= 0 && idx < g_st.search_song_count); if (ok) *out = g_st.search_songs[idx]; pthread_mutex_unlock(&g_search_mu); return ok ? NCM_OK : NCM_ERR_ARG; } if (appstate_song_copy(idx, out) == 0) return NCM_OK; return appstate_song_fetch(idx, out); } int appstate_search_count(int tab) { int c; pthread_mutex_lock(&g_search_mu); c = (tab == 0) ? g_st.search_song_count : g_st.search_pl_count; pthread_mutex_unlock(&g_search_mu); return c; } int appstate_search_offset(int tab) { int o; pthread_mutex_lock(&g_search_mu); o = (tab == 0) ? g_st.search_song_offset : g_st.search_pl_offset; pthread_mutex_unlock(&g_search_mu); return o; } int appstate_search_has_more(int tab) { int m; pthread_mutex_lock(&g_search_mu); m = (tab == 0) ? g_st.search_song_more : g_st.search_pl_more; pthread_mutex_unlock(&g_search_mu); return m; } int appstate_search_song(int i, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; pthread_mutex_lock(&g_search_mu); int ok = (i >= 0 && i < g_st.search_song_count); if (ok) *out = g_st.search_songs[i]; pthread_mutex_unlock(&g_search_mu); return ok ? NCM_OK : NCM_ERR_ARG; } int appstate_search_pl(int i, ncm_playlist_t *out) { if (!out) return NCM_ERR_ARG; pthread_mutex_lock(&g_search_mu); int ok = (i >= 0 && i < g_st.search_pl_count); if (ok) *out = g_st.search_pls[i]; pthread_mutex_unlock(&g_search_mu); return ok ? NCM_OK : NCM_ERR_ARG; } void appstate_search_clear(int tab) { pthread_mutex_lock(&g_search_mu); if (tab == 0) { g_st.search_song_count = 0; g_st.search_song_offset = 0; g_st.search_song_total = -1; g_st.search_song_more = 1; memset(g_st.search_songs, 0, sizeof g_st.search_songs); } else { g_st.search_pl_count = 0; g_st.search_pl_offset = 0; g_st.search_pl_total = -1; g_st.search_pl_more = 1; memset(g_st.search_pls, 0, sizeof g_st.search_pls); } pthread_mutex_unlock(&g_search_mu); } /* 追加单曲结果(worker 锁内):写 g_st.search_songs[count..),回填 offset/total/has_more。 */ void appstate_search_append_songs(int n, const ncm_song_t *s, int64_t total) { if (n <= 0 || !s) return; pthread_mutex_lock(&g_search_mu); int room = NCM_SEARCH_SONG_MAX - g_st.search_song_count; if (n > room) n = room; if (n > 0) memcpy(&g_st.search_songs[g_st.search_song_count], s, (size_t)n * sizeof(ncm_song_t)); g_st.search_song_count += n; g_st.search_song_offset = g_st.search_song_count; g_st.search_song_total = (total >= 0) ? (int)total : g_st.search_song_total; int t = g_st.search_song_total; g_st.search_song_more = (t >= 0) ? (g_st.search_song_count < t) : (n >= NCM_SEARCH_PAGE); pthread_mutex_unlock(&g_search_mu); } void appstate_search_append_pls(int n, const ncm_playlist_t *p, int64_t total) { if (n <= 0 || !p) return; pthread_mutex_lock(&g_search_mu); int room = NCM_SEARCH_PL_MAX - g_st.search_pl_count; if (n > room) n = room; if (n > 0) memcpy(&g_st.search_pls[g_st.search_pl_count], p, (size_t)n * sizeof(ncm_playlist_t)); g_st.search_pl_count += n; g_st.search_pl_offset = g_st.search_pl_count; g_st.search_pl_total = (total >= 0) ? (int)total : g_st.search_pl_total; int t = g_st.search_pl_total; g_st.search_pl_more = (t >= 0) ? (g_st.search_pl_count < t) : (n >= NCM_SEARCH_PAGE); pthread_mutex_unlock(&g_search_mu); } /* ---- 搜索历史(≤NCM_SEARCH_HIST_MAX,最新在前;单键 meow_config 持久化,条目以 \n 连接)---- */ #define NCM_SEARCH_HIST_KEY "ncm.search_history" static char g_hist[NCM_SEARCH_HIST_MAX][NCM_SEARCH_HIST_LEN]; static int g_hist_count; static int g_hist_loaded; static void srch_hist_load(void) { char buf[2048]; if (meow_config_get(NCM_SEARCH_HIST_KEY, buf, sizeof buf) != MEOW_CFG_OK) { g_hist_count = 0; return; } g_hist_count = 0; char *p = buf; while (p && *p && g_hist_count < NCM_SEARCH_HIST_MAX) { char *nl = strchr(p, '\n'); size_t len = nl ? (size_t)(nl - p) : strlen(p); if (len >= NCM_SEARCH_HIST_LEN) len = NCM_SEARCH_HIST_LEN - 1; if (len > 0) { memcpy(g_hist[g_hist_count], p, len); g_hist[g_hist_count][len] = 0; g_hist_count++; } if (!nl) break; p = nl + 1; } } static void srch_hist_save(void) { char buf[2048]; size_t bl = 0; buf[0] = 0; for (int i = 0; i < g_hist_count; i++) { int n = snprintf(buf + bl, sizeof buf - bl, "%s%s", i ? "\n" : "", g_hist[i]); if (n < 0) break; bl += (size_t)n; if (bl >= sizeof buf - 1) break; } meow_config_set(NCM_SEARCH_HIST_KEY, buf); } static void appstate_srch_hist_init(void) { if (!g_hist_loaded) { srch_hist_load(); g_hist_loaded = 1; } } int appstate_search_history_count(void) { appstate_srch_hist_init(); return g_hist_count; } int appstate_search_history_get(int i, char *out, size_t sz) { appstate_srch_hist_init(); if (i < 0 || i >= g_hist_count) return 0; ncm_strlcpy(out, g_hist[i], sz); return 1; } void appstate_search_history_add(const char *kw) { if (!kw || !kw[0]) return; appstate_srch_hist_init(); char tmp[NCM_SEARCH_HIST_MAX][NCM_SEARCH_HIST_LEN]; int m = 0; ncm_strlcpy(tmp[m], kw, sizeof tmp[m]); m++; for (int i = 0; i < g_hist_count && m < NCM_SEARCH_HIST_MAX; i++) if (strcmp(g_hist[i], kw) != 0) { ncm_strlcpy(tmp[m], g_hist[i], sizeof tmp[m]); m++; } g_hist_count = m; memcpy(g_hist, tmp, sizeof g_hist); srch_hist_save(); } void appstate_search_history_remove(int i) { appstate_srch_hist_init(); if (i < 0 || i >= g_hist_count) return; for (int j = i; j < g_hist_count - 1; j++) ncm_strlcpy(g_hist[j], g_hist[j + 1], sizeof g_hist[j]); g_hist_count--; srch_hist_save(); } void appstate_search_history_clear(void) { g_hist_count = 0; g_hist_loaded = 1; srch_hist_save(); }