
Nuclear 的本地收听历史从 UI 事件流到 SQLite 时间轴的完整实现【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclearNuclear 内置了一个完全本地化的收听历史Listening history系统它把每次播放拆解为 started、paused、resumed、seeked、skipped、stopped、finished 七种事件写入机器本地的 SQLite 数据库再还原出按天分组、可交互、可分页的历史视图并以此为底座计算精确到毫秒的收听时长。读完本文你将掌握该功能的数据模型、事件采集链路、数据库表结构、收听时长的窗口函数算法以及如何在本机定位和检查history.db这个文件本身。历史视图按天分组、可交互、可分页Nuclear 会记录你播放过的曲目你可以在左侧侧边栏的History视图中浏览全部收听历史。历史数据保存在你电脑本地的数据库中。视图的组织方式如下曲目按天分组最新的一天排在最前面最近两天显示为Today和Yesterday更早的日期则显示完整日期每条记录展示曲目的封面图、艺术家、标题以及播放时间。这些分组行为有对应的测试用例锁定。History.test.tsx 中的用例separates plays with day markers: Today, Yesterday, then calendar dates就断言了播放记录被 Today / Yesterday / 日历日期三类标记分隔展示。历史条目是交互式的点击爱心图标可将曲目加入或移出收藏favorite tracks点击曲目标题可再次播放悬停条目后点击按钮可将其加入播放队列queue。当历史条目多到一页放不下时底部会出现分页控件页面大小选择器支持每页显示10、25 或 50条。分页组件实现在 HistoryPaginationFooter.tsx页面大小的切换同样被集成测试覆盖测试中对pageSizeSelect依次选择了 25 和 10 并验证列表变化见 History.test.tsx。开关Record listening history 设置项历史默认开启。要关闭它进入设置并切换Record listening history开关。Nuclear 会立即停止记录新的播放而已有的历史记录保持不动。在源码中这个开关对应设置键core.history.enabled前端的判断逻辑非常直观——只要设置值不是显式的false历史功能就算开启默认开启// packages/player/src/services/history/historyService.ts const HISTORY_ENABLED_SETTING core.history.enabled; const isEnabled () getSetting(HISTORY_ENABLED_SETTING) ! false;关闭开关后所有事件记录函数recordStarted、recordDuringPlay、recordTerminal都会在入口处通过isEnabled()提前返回因此“立即停止记录新播放”这一行为是由前端事件采集层保证的而不是数据库层面的清理。数据模型一次播放是一串事件而不是一行记录Nuclear 记录的信息远不止“播放了哪些曲目”。文档中对齐的行业背景是这样的last.fm、libre.fm 这类 scrobbling 服务通常只记录“某曲目在某时刻被播放过”。这会带来一个统计偏差如果你喜欢的艺术家发布了 30 分钟的长曲目听一次就是一次而紧接着你听了一张同样 30 分钟、但由 10 首 3 分钟短曲组成的朋克专辑就会记成 10 次播放——但这并不意味着你更爱那个乐队 10 倍。为解决这个问题Nuclear 除了记录“播放了什么”还完整记录一次播放过程中的关键时刻曲目开始播放的时刻started你跳过了曲目skipped你在曲目内拖动/跳转seeked曲目被暂停和恢复paused / resumed曲目被停止stopped曲目被完整听完finished这些类型在后端有严格对应。types.rs 中定义了事件枚举与事件结构体pub enum PlayEventKind { Started, Paused, Resumed, Seeked, Finished, Skipped, Stopped, } pub struct PlayEvent { pub play_id: String, pub kind: PlayEventKind, pub at: i64, // 事件发生时间戳毫秒 pub position_ms: i64, // 事件发生时在曲目内的位置 pub seek_to_ms: Optioni64, // seeked 事件的目标位置 pub snapshot: OptionTrackSnapshot, // 仅 started 事件携带曲目快照 }其中TrackSnapshot在started事件中随事件一起落库保存了当时的标题、艺术家列表、专辑名、时长、封面 URL、provider 及 provider_id——相当于对曲目元数据的一份“时间切片”即便日后曲目信息变化历史条目仍能还原当时的样子。数据库层的迁移脚本也用CHECK约束锁死了这七种取值见 0001_init.sql 中kind IN (started, paused, resumed, seeked, finished, skipped, stopped)。这套细粒度事件数据让历史能更准确地反映你的偏好可以识别出你最喜欢哪些片段、最常跳过哪些曲目、以及在哪些曲目上花的时间最多。Stats 标签页正是基于这些数据按收听时长生成图表和 Top 榜单。数据库两张表加一个指纹收听历史是一个未加密的数据库存放在平台的 appdata 文件夹下各平台具体路径见 Platform specific paths文件位于./databases/history.db可以用任意 SQLite 数据库浏览器打开查看。从源码结构看数据库在应用启动时初始化history/mod.rs 中的init_history解析app_data_dir打开databases/history.db然后执行migrations/history目录下的迁移最后把连接池以HistoryDb注入 Tauri 全局状态。表结构初始迁移 0001_init.sql 创建了两张表CREATE TABLE tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, fingerprint TEXT NOT NULL UNIQUE, -- 曲目指纹去重核心 title TEXT NOT NULL, artists TEXT NOT NULL, -- JSON 数组字符串 album_title TEXT, duration_ms INTEGER, artwork_url TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE play_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, play_id TEXT NOT NULL, -- 同一次播放的事件共享 play_id track_id INTEGER REFERENCES tracks(id), kind TEXT NOT NULL CHECK (kind IN (started, paused, resumed, seeked, finished, skipped, stopped)), at INTEGER NOT NULL, -- 事件时间戳 position_ms INTEGER NOT NULL, -- 曲目内位置 seek_to_ms INTEGER, -- seeked 的目标位置 provider TEXT, provider_id TEXT ); CREATE INDEX idx_play_events_play_id ON play_events(play_id); CREATE INDEX idx_play_events_started_at ON play_events(at DESC) WHERE kind started;设计要点是play_events是纯事件流一次播放由play_id标识对应多行事件tracks是曲目维度的去重表通过fingerprint唯一键复用。部分索引idx_play_events_started_at只索引kind started的行正好服务于“按播放时间倒序分页列出历史”这一最核心的查询。曲目指纹与 upsert 语义指纹由 fingerprint.rs 生成艺术家名与标题先 trim、转小写、折叠连续空白多个艺术家以,连接最后与标题用不可见分隔符\x1F拼接。同名曲目大小写不同也会得到相同指纹有测试same_track_different_casing_produces_same_fingerprint验证。写入时writes.rs 的upsert_track使用ON CONFLICT(fingerprint) DO UPDATE并且用COALESCE(tracks.x, excluded.x)回填已有非空的专辑名、时长不会被后来的空值覆盖而缺失的封面等字段则能被补上。测试same_track_reuses_row_and_backfills_nulls明确断言了“同一曲目复用一行、NULL 字段被回填、已存在的值不被覆盖”这三个行为。此外record_event有一条硬性约束只有started事件允许携带 snapshot其他事件必须为空违反时直接返回错误而不落库测试snapshot_on_non_started_event_is_rejected与started_without_snapshot_is_rejected覆盖了两个方向的拒绝。后端还提供了delete_range(from, to)在一个事务中删除某时间区间内开始的所有播放事件并级联清理不再被任何播放引用的孤儿曲目——对应设置中按时间段清理历史的能力从源码结构看这是 UI 删除入口的底层支撑。收听时长一个窗口函数视图如何从事件流算出“一次播放真正听了多久”Nuclear 没有把时长写进某一行而是用第二个迁移 0002_play_listening_time.sql 创建了一个视图play_listening_time核心思路是先过滤掉seeked事件——seek 本身不产生收听用LAG(at)取得同一play_id内上一个事件的时间用at - prev_at得到每段“正在收听”的时长只对started → paused、resumed → paused/finished/skipped/stopped这类“收听结束”的行求和WHERE kind IN (paused,finished,skipped,stopped) AND prev_kind IN (started,resumed)同时用LAST_VALUE取出最后一次事件类型映射出end_reasonfinished / skipped / stopped和end_position_ms。这个算法有一个非常微妙的正确性边界暂停状态下拖动进度条不应计入收听时长。reads.rs 中的测试seeking_while_paused_does_not_count_as_listening_time构造了started(1000) → paused(2000) → seeked(3000) → resumed(4000) → finished(5000)的事件序列断言ms_played恰好等于 2000ms只有 started→paused 这一段而不是把 resumed 到 finished 的 1000ms 错误计入。读路径分页、排序与未完成的播放历史列表的查询在 reads.rs 的entries中SELECT e.play_id, e.provider, e.provider_id, t.title, t.artists, t.album_title, t.duration_ms, t.artwork_url, e.at AS started_at, COALESCE(p.ms_played, 0) AS ms_played, p.end_reason, p.end_position_ms FROM play_events e JOIN tracks t ON t.id e.track_id LEFT JOIN play_listening_time p ON p.play_id e.play_id WHERE e.kind started ORDER BY e.at DESC LIMIT ? OFFSET ?几个值得注意的细节以started事件为锚点JOIN tracks保证一行就是一条历史记录且自带完整曲目快照LEFT JOIN时长视图意味着一次尚未结束的播放也会出现在历史里——此时ms_played为 0、end_reason为 NULL。测试entries_includes_interrupted_plays验证了这一点ORDER BY e.at DESC LIMIT ? OFFSET ?直接支撑前端的 10/25/50 分页count_plays只统计kind started的行作为分页控件的总条目数。这些 Tauri 命令经 commands.rs 暴露给前端historyFetch返回PageHistoryEntryitems total另有historyRecordEvent用于写入事件以及historyHourlyListeningTime、historyDailyListeningTime、historyFirstPlayAt、historyTopArtists、historyTopAlbums、historyTopTracks等供 Stats 标签页使用的统计命令实现位于 stats/ 目录。前端类型与命令绑定由#[specta::specta]宏生成最终落到 bindings.ts 的historyRecordEvent等调用。前端事件采集从 eventBus 到 playId 生命周期前端把播放事件桥接到历史的逻辑集中在 historyService.ts。initHistoryService订阅eventBus上的七类事件与后端枚举一一对应eventBus 事件记录的事件 kind记录时机trackStartedstarted生成新 playIdhistoryStore.beginPlay()携带曲目 snapshottrackFinishedfinished结束当前 playIdplaybackSkippedskipped结束当前 playId携带positionMsplaybackStoppedstopped结束当前 playId携带positionMsplaybackPausedpaused播放中记录携带positionMsplaybackResumedresumed播放中记录携带positionMsplaybackSeekedseeked记录positionMs起点与seekToMs目标点快照构建时有一个实用细节封面通过pickArtwork(track.artwork, thumbnail, 256)选择约 256px 的缩略图存库避免把大图 URL 全部写进历史数据库。整个 playId 的生命周期beginPlay→currentPlayId→clearPlay由useHistoryStore维护保证 skipped/stopped/finished 这类“终止事件”总是关联到正确的一次播放。集成测试 ListeningHistory.test-wrapper.tsx 通过 mockhistoryRecordEvent命令验证了这条链路。如何查看本机数据库由于历史库是明文 SQLite你可以随时直接检查它找到你平台的 appdata 目录参考 Platform specific paths打开其下的./databases/history.db用任意 SQLite 工具浏览tracks表是去重后的曲目维度play_events表是原始事件流play_listening_time视图则是每段播放的收听时长汇总。一个快速体验 SQL统计最近一天内开始的播放数SELECT COUNT(*) FROM play_events WHERE kind started AND at (strftime(%s,now,-1 day) * 1000);小结Nuclear 的收听历史是一个“前端事件流 后端事件溯源 SQL 视图聚合”的三层结构前端 historyService.ts 负责在受core.history.enabled设置门控的前提下采集七种播放事件后端 writes.rs 以指纹去重曲目、以play_id串联事件0002 迁移的窗口函数视图 把事件流折叠成每段播放的收听时长与结束原因reads.rs 再把它还原为 History 视图里按天分组、可分页、含时长信息的条目。所有数据都留在你本机的history.db中未做任何加密这也是在享受收听统计Stats的同时需要知晓的隐私边界。【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclear创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考