Repository Wiki
ldx123000/Hydrogen-Music

曲库浏览、搜索与收藏

本页说明 Hydrogen Music 中曲库入口、搜索建议、曲库列表索引与收藏相关状态管理的实现边界。重点覆盖 SearchInput 的搜索交互和 libraryStore 的曲库数据、搜索索引、详情缓存及歌单概览状态;具体播放、下载、本地音乐扫描和账号认证属于其他能力页面。

Purpose and Scope

本页面向需要维护曲库浏览体验、搜索入口或收藏歌单状态的开发者,解释从用户输入到建议列表、曲库数据索引和视图状态更新的关键路径。

页面范围包括:

  • 曲库入口所覆盖的歌单、专辑、歌手、MV 等资源类型;
  • 顶部 SearchInput 的热搜/联想搜索、节流、防竞态和键盘交互;
  • libraryStore 对曲库列表、歌单概览、详情缓存、滚动位置和本地搜索索引的集中管理;
  • 收藏歌单、喜欢歌曲、添加到歌单等产品能力在曲库页面中的定位。

README 明确列出曲库可搜索歌曲、专辑、歌手、歌单和 MV,并支持收藏歌单管理、喜欢歌曲和添加到歌单等操作;但这些操作对应的具体 API 调用实现未在本次受限源码读取范围内展开。因此,本页不推断收藏接口的请求参数或服务端持久化协议。对于播放器队列、下载和本地音乐扫描,请参阅对应 sibling catalog 页面。

Overview

曲库能力采用“页面组件负责交互、Pinia store 负责跨页面状态、API 模块负责远端数据”的分层方式。SearchInput.vue 使用 usePlayerStore 读取搜索建议数量限制,调用 searchHotDetail、searchSuggest 和 searchSuggestPc 获取热搜或联想数据,再通过路由跳转到对应资源页面。libraryStore.js 则将歌单、专辑、歌手、MV 与歌曲详情相关状态统一保存,并维护按资源 ID 建立的搜索文本索引。

这种拆分的设计意图是把短生命周期的输入状态(关键词、候选项、当前高亮项)留在搜索组件内,把跨页面复用或需要在收藏/歌单更新后同步的状态放入 store。搜索索引使用预构建文本而不是在每次键盘输入时重新拼装对象字段,从而为曲库列表过滤提供稳定的查询入口。

Architecture

Loading diagram...

Sources:

App.vue 在非本地模式下渲染 SearchInput;搜索组件依赖 playerStore 和 api/other。libraryStore 直接导入歌单、专辑、歌手、MV 和歌曲 API,同时把搜索文本生成器和多个缓存/索引对象纳入同一状态边界。图中的 LibraryViews 是对实际曲库视图调用方的概括,源码中具体视图组件未在本次读取范围内展开,不能据此推断更多页面层级。

关键状态与职责

libraryStore 的状态分组

store 的初始状态将曲库能力拆成几组:

状态作用
libraryList、libraryListAlbum、libraryListAritist保存当前曲库列表及分类列表
playlistCount、playlistUserCreated、playlistUserSub保存用户创建/订阅歌单及数量信息
libraryInfo保存当前曲库详情概览
lastLibraryRoute、lastLibraryScrollTop、restoreLibraryScrollOnActivate支持曲库页面返回时恢复路由和滚动位置
detailScrollMemory、detailCache保存详情页滚动位置和有限详情缓存
librarySongs、libraryAlbum、libraryMV保存歌曲、专辑和 MV 维度的曲库数据
searchIndexById按 songs、albums、mvs 分组保存搜索文本
needTimestamp记录需要时间戳处理的 URL

初始化时还设置了 detailScrollMemoryLimit = 100 和 detailCacheLimit = 20,说明滚动记忆与详情缓存均有边界,而不是无限增长。歌单 hydration 状态则通过 playlistHydration、token 和 promise 字段协调异步加载;本次读取的前 240 行确认了其状态结构和重置动作,但未展开完整 hydration 流程。

歌单概览的局部更新

updatePlaylistOverviewTrackCount 和 setPlaylistOverviewTrackCount 都先把 playlist ID 转成字符串,并拒绝空 ID、非数值 delta 或非法数量。更新时只复制匹配项目,分别处理 trackCount 与 size 字段,然后同步更新创建歌单、订阅歌单、当前列表和当前详情中的同一 playlist。

这一设计避免在添加/移除歌曲后重新请求所有歌单概览,同时通过 Math.max(0, ...) 防止计数降到负数。markPlaylistOverviewStale 则递增版本号,并保存是否静默刷新,给外部视图一个可观察的刷新信号。

账号切换与索引一致性

resetAccountState 会清空歌单、详情、滚动记忆、曲库数据和 hydration 状态,并调用 resetSearchIndex()。这保证退出账号或切换账号后不会把上一账号的歌单和搜索索引泄漏到新会话。resetSearchIndex(section) 支持全量重置,也支持只清理某一个资源分区。

Core Flow

Loading diagram...

Sources:

实际组件把热搜和联想搜索统一为 currentList:关键词非空时取 suggestList,否则取 hotList,再按 assistLimit 截断。候选标题和空状态也随模式切换为 SUGGESTIONS/HOT SEARCH 以及相应提示。空关键词不会被直接当作联想词,而是通过 JTrim 判断是否进入 suggest 模式。

搜索建议实现

规范化、去重与资源类型

JTrim 将输入转为字符串并去除首尾空白;normalizeSuggestKey 再将关键词转为小写,用于大小写不敏感去重。appendSuggestItem 只接受非空关键词,并把 keyword、资源 type 和 source 保存到候选对象中,同时按插入顺序生成 order。

Web 建议解析器读取 data.result,默认按 songs、artists、playlists、albums 顺序处理,也尊重服务端返回的 result.order。资源类型映射为歌曲 1、歌手 100、歌单 1000、专辑 10。PC 建议则读取 data.data.suggests,通过 relatedResource.resourceType 或 resourceType 做同样的映射。未知类型返回 0,源码没有声明未知类型的额外跳转策略。

候选列表与键盘导航

组件维护 activeAssistIndex、assistVisible、loadingSuggest 等状态。setActiveAssistIndex 会把索引限制在当前列表范围内,等待 nextTick 后定位 DOM 中的 .assist-item,并根据 start、end 或 nearest 对滚动位置进行钳制。moveAssistSelection 在首尾之间循环,方向键导航时还会暂时抑制鼠标 hover,避免键盘和鼠标同时改变高亮项。

源码明确在输入法组合期间忽略 ArrowDown:handleArrowDown 先检查 isComposing,再调用导航函数。这是中文输入场景的重要边界,防止用户选字时误触发建议项移动。

Usage Examples

初始化并维护曲库搜索索引

以下是 store 中实际使用的索引构建方式。歌曲支持 append,因此分页加载时可以保留已有 ID;专辑和 MV 则以传入数组重建各自分区。搜索文本由 songFilter 工具生成,避免把字段拼接规则散落在视图组件中。

javascript
1indexLibrarySongs(songs, { append = false } = {}) { 2 const nextSongsIndex = append ? { ...(this.searchIndexById?.songs || {}) } : {} 3 const targetSongs = Array.isArray(songs) ? songs : [] 4 targetSongs.forEach((song, index) => { 5 nextSongsIndex[getSearchEntryKey(song, `song-${index}`)] = buildCloudSongSearchText(song) 6 }) 7 this.searchIndexById = { 8 ...this.searchIndexById, 9 songs: nextSongsIndex, 10 } 11}, 12indexLibraryAlbums(albums) { 13 const nextAlbumsIndex = {} 14 const targetAlbums = Array.isArray(albums) ? albums : [] 15 targetAlbums.forEach((album, index) => { 16 nextAlbumsIndex[getSearchEntryKey(album, `album-${index}`)] = buildAlbumSearchText(album) 17 }) 18 this.searchIndexById = { 19 ...this.searchIndexById, 20 albums: nextAlbumsIndex, 21 } 22}, 23indexLibraryMVs(mvs) { 24 const nextMvsIndex = {} 25 const targetMvs = Array.isArray(mvs) ? mvs : [] 26 targetMvs.forEach((mv, index) => { 27 nextMvsIndex[getSearchEntryKey(mv, `mv-${index}`)] = buildMVSearchText(mv) 28 }) 29 this.searchIndexById = { 30 ...this.searchIndexById, 31 mvs: nextMvsIndex, 32 } 33},

Source: libraryStore.js

读取索引并在缺失时即时回退

读取方法优先使用按 ID 保存的文本;如果条目尚未建立索引,则即时调用相同的构建函数生成文本。这使分页、懒加载或刚切换账号的短暂窗口不会直接得到空搜索文本。

javascript
1getSongSearchText(song, fallbackKey = '0') { 2 return this.searchIndexById?.songs?.[getSearchEntryKey(song, fallbackKey)] || buildCloudSongSearchText(song) 3}, 4getAlbumSearchText(album, fallbackKey = '0') { 5 return this.searchIndexById?.albums?.[getSearchEntryKey(album, fallbackKey)] || buildAlbumSearchText(album) 6},

Source: libraryStore.js

搜索建议显示模式

组件通过计算属性统一决定当前显示的数据源和数量。playerStore.searchAssistLimit 是配置入口;组件会把配置转换成至少为 1 的整数,非法值回退到 8。

javascript
1const assistLimit = computed(() => normalizeAssistLimit(playerStore.searchAssistLimit)) 2const songLinkId = computed(() => getSongIdFromLink(searchKeyword.value)) 3const isSuggestMode = computed(() => JTrim(searchKeyword.value) !== '') 4const currentList = computed(() => { 5 const list = isSuggestMode.value ? suggestList.value : hotList.value 6 return list.slice(0, assistLimit.value) 7}) 8const currentTitle = computed(() => (isSuggestMode.value ? 'SUGGESTIONS' : 'HOT SEARCH')) 9const currentEmptyText = computed(() => (isSuggestMode.value ? 'NO SUGGESTION' : 'NO HOT SEARCH')) 10const currentLoading = computed(() => (isSuggestMode.value ? loadingSuggest.value : loadingHot.value))

Source: SearchInput.vue

解析 PC 搜索建议

PC 建议返回的是 data.suggests,组件保留每个建议的关键词,并通过资源类型确定后续路由或搜索行为所需的类型码。资源类型读取同时兼容 relatedResource.resourceType 和顶层 resourceType。

javascript
1function getPcSuggestType(item) { 2 const resourceType = String(item?.relatedResource?.resourceType || item?.resourceType || '').toLowerCase() 3 if (resourceType === 'song') return 1 4 if (resourceType === 'artist') return 100 5 if (resourceType === 'album') return 10 6 if (resourceType === 'playlist') return 1000 7 return 0 8} 9 10function parsePcSuggestItems(data) { 11 const suggests = Array.isArray(data?.data?.suggests) ? data.data.suggests : [] 12 return suggests.map(item => ({ 13 keyword: item?.keyword, 14 type: getPcSuggestType(item), 15 })) 16}

Source: SearchInput.vue

Configuration Options

选项类型默认值说明
DEFAULT_ASSIST_LIMITnumber8搜索建议显示数量的默认上限。
MIN_ASSIST_LIMITnumber1搜索建议数量的最小值。
SUGGEST_DEBOUNCE_MSnumber220搜索联想请求使用的防抖时间,单位为毫秒。
ASSIST_HOVER_ACTIVATE_DELAY_MSnumber140建议项 hover 激活延迟,单位为毫秒。
playerStore.searchAssistLimit可转换为整数的值未在本页源码中声明通过 normalizeAssistLimit 转换;非法值使用 DEFAULT_ASSIST_LIMIT,合法值不会低于 MIN_ASSIST_LIMIT。
detailScrollMemoryLimitnumber100libraryStore 中详情滚动位置记录的限制。
detailCacheLimitnumber20libraryStore 中详情缓存的限制。
PLAYLIST_PAGE_SIZEnumber100歌单处理相关的页面大小常量。
PLAYLIST_HYDRATION_CONCURRENCYnumber4歌单 hydration 的并发常量。

源码展示了这些常量和状态默认值,但本次读取范围未包含 searchAssistLimit 的持久化配置来源,也未包含 hydration 调度函数的完整实现;因此不能进一步断言它们是否可被环境变量或用户设置覆盖。

API Reference

useLibraryStore()

Pinia store 工厂,名称为 libraryStore。返回包含曲库列表、歌单概览、缓存、滚动记忆和搜索索引的 store 实例。

resetSearchIndex(section = null)

重置搜索索引。省略 section 时重建完整的 songs、albums、mvs 空索引;传入分区名时仅替换该分区。源码没有对未知分区名做白名单校验,因此调用方应只传入实际索引分区。

indexLibrarySongs(songs, { append = false } = {})

将歌曲数组转换为按 ID 索引的搜索文本。songs 不是数组时按空数组处理;默认全量替换歌曲索引,append: true 时保留旧歌曲索引。

indexLibraryAlbums(albums) / indexLibraryMVs(mvs)

分别重建专辑或 MV 搜索索引。非数组输入会产生空索引。

getSongSearchText(song, fallbackKey = '0') / getAlbumSearchText(album, fallbackKey = '0')

按资源 ID 读取预构建搜索文本;索引不存在时调用对应 builder 即时生成。源码在本次读取范围内展示了歌曲与专辑方法,MV 的读取方法未展开,不能据此补写其签名。

updatePlaylistOverviewTrackCount(playlistId, delta)

将匹配 playlist 的 trackCount 和 size 增加 delta,并把更新传播到创建列表、订阅列表、当前列表及当前详情。空 ID、非有限 delta 或零 delta 会直接返回;计数结果不会小于零。

setPlaylistOverviewTrackCount(playlistId, trackCount)

把匹配 playlist 的概览计数设置为不小于零的数值,并同步多个歌单视图状态。空 ID或非有限数量会直接返回。

resetAccountState()

清除当前账号的曲库、歌单、详情缓存、滚动位置、hydration 和搜索索引状态。它是账号边界的重要清理点。

Failure Modes、Edge Cases 与并发

输入和远端响应边界

  • 搜索关键词会先 trim;空字符串显示热搜而不是发送普通联想词。
  • 建议解析器对缺失的 result、缺失数组和未知资源类型使用安全回退,不会假设字段一定存在。
  • normalizeAssistLimit 对不可解析值回退为 8,并将小于 1 的值钳制为 1。
  • setActiveAssistIndex 在建议不可见或列表为空时重置高亮,防止 DOM 索引越界。
  • setActiveAssistIndex 对滚动目标进行上限/下限钳制,避免滚动容器出现超范围值。

防抖、过期请求与生命周期

组件维护 debounceTimer、hoverActivateTimer 和 requestSeq,并提供清理函数;源码片段显示了这些状态和 onUnmounted 的导入,表明组件将计时器生命周期纳入卸载处理。具体请求序列号如何判定响应过期,需读取文件后续实现才能确认,本页不推断其细节。

并发与一致性

store 中定义了 PLAYLIST_HYDRATION_CONCURRENCY = 4,并保存 playlistHydrationPromise 与随机 token,用于避免 hydration 状态混乱;已确认 resetPlaylistHydration 会同时清理状态、token 和 promise。完整并发调度逻辑未在受限读取范围内发现,因此这里只记录源码明确可见的同步边界。

索引更新采用新对象赋值,而不是原地修改已有分区。这让 Pinia/Vue 更容易观察到引用变化;同时歌曲追加模式只复制 songs 分区,避免无关的专辑和 MV 索引被覆盖。

Performance 与 Operational Notes

  1. 搜索建议使用 220ms 防抖常量,减少每次键击都请求远端接口的压力。
  2. 建议列表通过 assistLimit 截断,渲染数量不会直接等于服务端返回总量。
  3. detailCacheLimit = 20、detailScrollMemoryLimit = 100 为客户端内存提供显式边界。
  4. 歌曲索引支持增量追加,适合分页或分批载入;专辑和 MV 索引则以完整数组重建,调用方需要避免在高频输入事件中重复重建。
  5. 详情 API、歌单 hydration 的网络重试、超时和错误提示未在本次读取范围内发现,不能补充未验证的运行保证。

Extension Points

  • 新增搜索资源类型时,需要同时调整解析器的资源类型映射、候选去重/展示逻辑和提交后的路由处理;仅添加 API 字段不足以完成端到端支持。
  • 新增曲库可过滤资源时,应在 createSearchIndexState、对应 indexLibrary* 方法以及读取回退方法中保持同一分区命名。
  • 收藏能力若要影响曲库概览,应复用 markPlaylistOverviewStale、updatePlaylistOverviewTrackCount 或 setPlaylistOverviewTrackCount 这类局部状态动作,而不是直接修改组件内部列表;具体采用哪一种取决于收藏 API 返回的是增量还是最终计数。
  • 账号边界相关扩展必须接入 resetAccountState,否则新账号可能继承旧账号的详情缓存或搜索索引。

Sources

(3 files)
(root)
src/components