use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use arc_swap::ArcSwap;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use mineral_log::chain;
use mineral_model::{Song, SourceKind};
use mineral_protocol::PlaybackOrigin as WirePlaybackOrigin;
use mineral_stats::{
Actor, AudioBackend, BehaviorEvent, FinishReason, LifecyclePhase, LifecycleWho,
PlayAudioSnapshot, PlayOrigin, PlayRecord, PlaybackOrigin, QueueContext, Retention,
SearchOutcome, SearchQueryMode, SearchTargetKind, SessionDecision, SessionTracker, StatsEvent,
StatsParams, StatsStore, query_hash,
};
const SECONDS_PER_DAY: u64 = 86_400;
const MS_PER_DAY: i64 = 86_400_000;
const CHANNEL_CAPACITY: usize = 4096;
pub struct PendingPlay {
pub song: Song,
pub started_at: i64,
pub origin: PlayOrigin,
pub actor: Actor,
pub context: QueueContext,
pub play_mode: mineral_stats::PlayMode,
pub duration_ms_snapshot: Option<i64>,
pub audio: PlayAudioSnapshot,
pub playback_origin: PlaybackOrigin,
}
enum StatsCommand {
PlayStarted(Box<PendingPlay>),
PlayEnded {
ended_at: i64,
listen_ms: i64,
finish_reason: FinishReason,
skip_at_ms: Option<i64>,
},
Event {
ts: i64,
event: Box<StatsEvent>,
},
EnrichAudio(PlayAudioSnapshot, WirePlaybackOrigin),
Flush {
ack: oneshot::Sender<()>,
},
Shutdown,
}
pub fn now_ms() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let elapsed = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
Some(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX))
}
fn off_params() -> StatsParams {
StatsParams::builder()
.level(mineral_stats::Level::Off)
.collect(rustc_hash::FxHashMap::default())
.search_queries(mineral_stats::SearchQueryMode::Off)
.exclude_sources(rustc_hash::FxHashSet::default())
.gap_ms(0)
.retention(mineral_stats::Retention::Forever)
.build()
}
pub fn stats_play_mode(mode: mineral_protocol::PlayMode) -> mineral_stats::PlayMode {
match mode {
mineral_protocol::PlayMode::Sequential => mineral_stats::PlayMode::Sequential,
mineral_protocol::PlayMode::Shuffle => mineral_stats::PlayMode::Shuffle,
mineral_protocol::PlayMode::RepeatAll => mineral_stats::PlayMode::RepeatAll,
mineral_protocol::PlayMode::RepeatOne => mineral_stats::PlayMode::RepeatOne,
}
}
pub fn pending_from_start(
song: Song,
play_mode: mineral_stats::PlayMode,
duration_ms_snapshot: Option<i64>,
playback_origin: WirePlaybackOrigin,
play_origin: PlayOrigin,
actor: Actor,
context: QueueContext,
) -> Option<PendingPlay> {
Some(PendingPlay {
song,
started_at: now_ms()?,
origin: play_origin,
actor,
context,
play_mode,
duration_ms_snapshot,
audio: PlayAudioSnapshot::default(),
playback_origin: stats_playback_origin(playback_origin),
})
}
fn stats_playback_origin(origin: WirePlaybackOrigin) -> PlaybackOrigin {
match origin {
WirePlaybackOrigin::Download => PlaybackOrigin::Download,
WirePlaybackOrigin::Cache => PlaybackOrigin::Cache,
WirePlaybackOrigin::Remote => PlaybackOrigin::Remote,
}
}
#[derive(Clone)]
pub struct StatsRecorder {
params: Arc<ArcSwap<StatsParams>>,
tx: Option<mpsc::Sender<StatsCommand>>,
store: StatsStore,
dropped: Arc<AtomicU64>,
}
impl StatsRecorder {
pub fn spawn(store: StatsStore, params: StatsParams) -> (Self, JoinHandle<()>) {
let params = Arc::new(ArcSwap::from_pointee(params));
let (tx, rx) = mpsc::channel::<StatsCommand>(CHANNEL_CAPACITY);
let handle = tokio::spawn(run(rx, store.clone(), Arc::clone(¶ms)));
(
Self {
params,
tx: Some(tx),
store,
dropped: Arc::new(AtomicU64::new(0)),
},
handle,
)
}
pub fn disabled() -> Self {
Self {
params: Arc::new(ArcSwap::from_pointee(off_params())),
tx: None,
store: StatsStore::disabled(),
dropped: Arc::new(AtomicU64::new(0)),
}
}
pub fn store(&self) -> &StatsStore {
&self.store
}
pub fn records_plays_for(&self, source: SourceKind) -> bool {
if !self.store.enabled() {
return false;
}
let params = self.params.load();
params.records_plays() && !params.excludes_source(source.name())
}
pub(crate) fn play_counts_enabled(&self) -> bool {
self.store.enabled() && self.params.load().records_plays()
}
pub fn dropped_count(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
pub fn set_params(&self, params: StatsParams) {
self.params.store(Arc::new(params));
}
pub async fn shutdown(&self) {
let Some(tx) = &self.tx else {
return;
};
let _ = tx.send(StatsCommand::Shutdown).await;
}
pub fn play_started(&self, mut pending: PendingPlay) {
{
let params = self.params.load();
if !params.records_plays() || params.excludes_source(pending.song.id.namespace().name())
{
return;
}
pending.context = pending.context.redact_search(params.search_queries());
}
self.send(StatsCommand::PlayStarted(Box::new(pending)));
}
pub fn play_ended(&self, finish_reason: FinishReason, listen_ms: i64) {
let Some(ended_at) = now_ms() else {
return;
};
let skip_at_ms = matches!(finish_reason, FinishReason::Skip).then_some(listen_ms);
self.send(StatsCommand::PlayEnded {
ended_at,
listen_ms,
finish_reason,
skip_at_ms,
});
}
pub fn daemon_started(&self, audio_backend: AudioBackend, session_restored: bool) {
self.event(StatsEvent::Behavior {
actor: Actor::System,
event: BehaviorEvent::AppLifecycle {
who: LifecycleWho::Daemon,
phase: LifecyclePhase::Start,
audio_backend: Some(audio_backend),
session_restored: Some(session_restored),
client_version: None,
},
});
}
pub fn daemon_stopped(&self) {
self.event(StatsEvent::Behavior {
actor: Actor::System,
event: BehaviorEvent::AppLifecycle {
who: LifecycleWho::Daemon,
phase: LifecyclePhase::Stop,
audio_backend: None,
session_restored: None,
client_version: None,
},
});
}
pub async fn flush(&self) {
let Some(tx) = &self.tx else {
return;
};
let (ack_tx, ack_rx) = oneshot::channel();
if tx.send(StatsCommand::Flush { ack: ack_tx }).await.is_err() {
return; }
let _ = ack_rx.await;
}
#[allow(clippy::too_many_arguments)] pub fn record_search(
&self,
actor: Actor,
raw_query: &str,
kind: SearchTargetKind,
source: SourceKind,
page: i64,
result_count: Option<i64>,
outcome: SearchOutcome,
) {
let query = match self.params.load().search_queries() {
SearchQueryMode::Off => return,
SearchQueryMode::Hashed => None,
SearchQueryMode::Raw => Some(raw_query.to_owned()),
};
self.event(StatsEvent::Behavior {
actor,
event: BehaviorEvent::Search {
query,
query_hash: query_hash(raw_query),
kind,
source,
page,
result_count,
outcome,
},
});
}
pub fn event(&self, event: StatsEvent) {
{
let params = self.params.load();
if !params.collects_event(event.kind_name()) {
return;
}
if event
.source_name()
.is_some_and(|source| params.excludes_source(source))
{
return;
}
}
let Some(ts) = now_ms() else {
return;
};
self.send(StatsCommand::Event {
ts,
event: Box::new(event),
});
}
pub fn enrich_play_audio(&self, audio: PlayAudioSnapshot, origin: WirePlaybackOrigin) {
self.send(StatsCommand::EnrichAudio(audio, origin));
}
fn send(&self, cmd: StatsCommand) {
let Some(tx) = &self.tx else {
return;
};
if tx.try_send(cmd).is_err() {
let total = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
mineral_log::warn!(target: "stats", dropped_total = total, "埋点通道满或已关,丢弃一条命令");
}
}
}
async fn run(
mut rx: mpsc::Receiver<StatsCommand>,
store: StatsStore,
params: Arc<ArcSwap<StatsParams>>,
) {
let mut tracker = SessionTracker::default();
let mut pending: Option<(PendingPlay, i64)> = None;
let start = tokio::time::Instant::now() + Duration::from_secs(SECONDS_PER_DAY);
let mut retention_tick = tokio::time::interval_at(start, Duration::from_secs(SECONDS_PER_DAY));
retention_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
maybe_cmd = rx.recv() => {
let Some(cmd) = maybe_cmd else { break };
if !process_command(cmd, &store, &mut tracker, &mut pending, ¶ms).await {
break;
}
}
_ = retention_tick.tick() => run_retention(&store, ¶ms).await,
}
}
settle_pending_as_stop(&store, &mut pending).await;
}
async fn process_command(
cmd: StatsCommand,
store: &StatsStore,
tracker: &mut SessionTracker,
pending: &mut Option<(PendingPlay, i64)>,
params: &Arc<ArcSwap<StatsParams>>,
) -> bool {
match cmd {
StatsCommand::PlayStarted(started) => {
if let Some((snapshot, session_id)) = pending.take() {
mineral_log::warn!(
target: "stats",
song_id = snapshot.song.id.as_str(),
"起播时发现未结算的在播行,按 skip 自愈结算(上游漏结算?)"
);
let ended_at = started.started_at;
let mut listen_ms = ended_at.saturating_sub(snapshot.started_at).max(0);
if let Some(duration) = snapshot.duration_ms_snapshot {
listen_ms = listen_ms.min(duration);
}
let record = assemble(
snapshot,
session_id,
ended_at,
listen_ms,
FinishReason::Skip,
Some(listen_ms),
);
if let Err(e) = store.record_play(&record).await {
mineral_log::warn!(target: "stats", error = chain(&e), "自愈结算 record_play 失败");
}
if let Err(e) = store.touch_session(session_id, ended_at).await {
mineral_log::warn!(target: "stats", error = chain(&e), "自愈结算 touch_session 失败");
}
}
if let Err(e) = store.upsert_song(&started.song).await {
mineral_log::warn!(target: "stats", error = chain(&e), "upsert_song 失败");
}
let gap_ms = params.load().gap_ms();
*pending = assign_session(store, tracker, started.started_at, gap_ms)
.await
.map(|session_id| (*started, session_id));
}
StatsCommand::PlayEnded {
ended_at,
listen_ms,
finish_reason,
skip_at_ms,
} => {
if let Some((snapshot, session_id)) = pending.take() {
let record = assemble(
snapshot,
session_id,
ended_at,
listen_ms,
finish_reason,
skip_at_ms,
);
if let Err(e) = store.record_play(&record).await {
mineral_log::warn!(target: "stats", error = chain(&e), "record_play 失败");
}
if let Err(e) = store.touch_session(session_id, ended_at).await {
mineral_log::warn!(target: "stats", error = chain(&e), "touch_session 失败");
}
}
}
StatsCommand::Event { ts, event } => {
let session_id = tracker.current_id();
if let Err(e) = store.record_event(ts, session_id, &event).await {
mineral_log::warn!(target: "stats", error = chain(&e), "record_event 失败");
}
}
StatsCommand::EnrichAudio(audio, origin) => {
if let Some((snapshot, _)) = pending.as_mut() {
snapshot.audio = audio;
snapshot.playback_origin = stats_playback_origin(origin);
}
}
StatsCommand::Flush { ack } => {
let _ = ack.send(());
}
StatsCommand::Shutdown => return false,
}
true
}
async fn settle_pending_as_stop(store: &StatsStore, pending: &mut Option<(PendingPlay, i64)>) {
let Some((snapshot, session_id)) = pending.take() else {
return;
};
let Some(ended_at) = now_ms() else {
mineral_log::warn!(target: "stats", "系统时钟异常,停机结算丢弃在播行");
return;
};
let listen_ms = ended_at.saturating_sub(snapshot.started_at).max(0);
let record = assemble(
snapshot,
session_id,
ended_at,
listen_ms,
FinishReason::Stop,
None,
);
if let Err(e) = store.record_play(&record).await {
mineral_log::warn!(target: "stats", error = chain(&e), "停机结算 record_play 失败");
}
if let Err(e) = store.touch_session(session_id, ended_at).await {
mineral_log::warn!(target: "stats", error = chain(&e), "停机结算 touch_session 失败");
}
}
async fn run_retention(store: &StatsStore, params: &Arc<ArcSwap<StatsParams>>) {
let Retention::Days(days) = params.load().retention() else {
return;
};
let Some(before_ms) = retention_before_ms(days) else {
return;
};
if let Err(e) = store.prune(before_ms).await {
mineral_log::warn!(target: "stats", error = chain(&e), "retention prune 失败");
}
}
fn retention_before_ms(days: u32) -> Option<i64> {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis();
let now_ms = i64::try_from(now_ms).ok()?;
let window = i64::from(days).checked_mul(MS_PER_DAY)?;
Some(now_ms.saturating_sub(window))
}
async fn assign_session(
store: &StatsStore,
tracker: &mut SessionTracker,
started_at: i64,
gap_ms: i64,
) -> Option<i64> {
match tracker.on_activity(started_at, gap_ms) {
SessionDecision::Continue { session_id } => {
if let Err(e) = store.touch_session(session_id, started_at).await {
mineral_log::warn!(target: "stats", error = chain(&e), "续会话 touch 失败");
}
Some(session_id)
}
SessionDecision::StartNew => match store.open_session(started_at).await {
Ok(Some(id)) => {
tracker.begin(id, started_at);
Some(id)
}
Ok(None) => None,
Err(e) => {
mineral_log::warn!(target: "stats", error = chain(&e), "开会话失败");
None
}
},
}
}
fn assemble(
snapshot: PendingPlay,
session_id: i64,
ended_at: i64,
listen_ms: i64,
finish_reason: FinishReason,
skip_at_ms: Option<i64>,
) -> PlayRecord {
PlayRecord {
song_id: snapshot.song.id,
started_at: snapshot.started_at,
ended_at,
listen_ms,
duration_ms_snapshot: snapshot.duration_ms_snapshot,
finish_reason,
skip_at_ms,
play_mode: snapshot.play_mode,
session_id,
origin: snapshot.origin,
actor: snapshot.actor,
context: snapshot.context,
audio: snapshot.audio,
playback_origin: snapshot.playback_origin,
}
}
#[cfg(test)]
mod tests {
use super::{PendingPlay, StatsRecorder};
use arc_swap::ArcSwap;
use mineral_model::{SongId, SourceKind};
use mineral_stats::{
Actor, FinishReason, Level, PlayOrigin, PlaybackOrigin, QueueContext, Retention,
SearchQueryMode, StatsParams, StatsStore,
};
use rustc_hash::{FxHashMap, FxHashSet};
use std::sync::Arc;
fn full_params() -> StatsParams {
StatsParams::builder()
.level(Level::Full)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Raw)
.exclude_sources(FxHashSet::default())
.gap_ms(30 * 60_000)
.retention(Retention::Forever)
.build()
}
fn pending(started_at: i64) -> PendingPlay {
PendingPlay {
song: mineral_test::with_name(mineral_test::song("1"), "栞"),
started_at,
origin: PlayOrigin::Explicit,
actor: Actor::User,
context: QueueContext::Unknown,
play_mode: mineral_stats::PlayMode::Sequential,
duration_ms_snapshot: Some(200_000),
audio: mineral_stats::PlayAudioSnapshot::default(),
playback_origin: PlaybackOrigin::Remote,
}
}
async fn temp_store() -> color_eyre::Result<(tempfile::TempDir, StatsStore)> {
let dir = tempfile::tempdir()?;
let store = StatsStore::open(&dir.path().join("stats.db")).await?;
Ok((dir, store))
}
#[tokio::test]
async fn records_plays_for_reflects_runtime_policy() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, _actor) = StatsRecorder::spawn(store, full_params());
assert!(recorder.records_plays_for(SourceKind::NETEASE));
let mut excluded = FxHashSet::default();
excluded.insert(SourceKind::BILIBILI.name().to_owned());
recorder.set_params(
StatsParams::builder()
.level(Level::Core)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Off)
.exclude_sources(excluded)
.gap_ms(30 * 60_000)
.retention(Retention::Forever)
.build(),
);
assert!(recorder.records_plays_for(SourceKind::NETEASE));
assert!(!recorder.records_plays_for(SourceKind::BILIBILI));
recorder.set_params(super::off_params());
assert!(!recorder.records_plays_for(SourceKind::NETEASE));
assert!(!StatsRecorder::disabled().records_plays_for(SourceKind::NETEASE));
Ok(())
}
fn days_params(days: u32) -> StatsParams {
StatsParams::builder()
.level(Level::Full)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Raw)
.exclude_sources(FxHashSet::default())
.gap_ms(30 * 60_000)
.retention(Retention::Days(days))
.build()
}
async fn seed_play(store: &StatsStore, started_at: i64) -> color_eyre::Result<()> {
let session_id = store
.open_session(started_at)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("期望会话 id"))?;
let record = super::assemble(
pending(started_at),
session_id,
started_at,
1000,
FinishReason::Eof,
None,
);
store.record_play(&record).await?;
Ok(())
}
#[test]
fn pending_from_start_carries_actor_and_origin() -> color_eyre::Result<()> {
use mineral_stats::PlayOrigin;
let mk = |origin, actor| {
super::pending_from_start(
mineral_test::song("1"),
mineral_stats::PlayMode::Sequential,
Some(1000),
mineral_protocol::PlaybackOrigin::Remote,
origin,
actor,
QueueContext::Unknown,
)
.ok_or_else(|| color_eyre::eyre::eyre!("正常时钟下应产出快照"))
};
let explicit = mk(PlayOrigin::Explicit, Actor::User)?;
assert_eq!(explicit.origin, PlayOrigin::Explicit);
assert_eq!(explicit.actor, Actor::User);
assert_eq!(mk(PlayOrigin::AutoAdvance, Actor::User)?.actor, Actor::User);
assert_eq!(
mk(PlayOrigin::AutoAdvance, Actor::Script)?.actor,
Actor::Script
);
assert_eq!(
mk(PlayOrigin::AutoAdvance, Actor::System)?.actor,
Actor::System
);
let with_ctx = super::pending_from_start(
mineral_test::song("1"),
mineral_stats::PlayMode::Sequential,
None,
mineral_protocol::PlaybackOrigin::Remote,
PlayOrigin::Explicit,
Actor::User,
QueueContext::Playlist {
id: mineral_model::PlaylistId::new(SourceKind::NETEASE, "7"),
name: None,
},
)
.ok_or_else(|| color_eyre::eyre::eyre!("正常时钟下应产出快照"))?;
assert!(matches!(with_ctx.context, QueueContext::Playlist { .. }));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn play_started_writes_song_dim() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, handle) = StatsRecorder::spawn(store.clone(), full_params());
recorder.play_started(pending(1000));
recorder.play_ended(FinishReason::Eof, 3000);
drop(recorder);
handle.await?;
let options = mineral_stats::ReportOptions::builder()
.min_listen_ms(0)
.top_limit(10)
.build();
let tops = store
.top_songs(0..i64::MAX, mineral_stats::TopBy::Plays, &options)
.await?;
let first = tops
.first()
.ok_or_else(|| color_eyre::eyre::eyre!("应有 top 行"))?;
assert_eq!(first.name.as_deref(), Some("栞"), "维表名随起播落库");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unsettled_pending_self_heals_as_skip() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, handle) = StatsRecorder::spawn(store.clone(), full_params());
recorder.play_started(pending(1000));
recorder.play_started(pending(60_000)); recorder.play_ended(FinishReason::Eof, 50_000);
drop(recorder);
handle.await?;
let totals = store.totals(0..i64::MAX).await?;
assert_eq!(totals.plays, 2, "被顶掉的第一首自愈落行,不丢");
assert_eq!(totals.completed, 1, "自愈行按 skip 计,非完播");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exclude_sources_gates_events_too() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let params = StatsParams::builder()
.level(Level::Full)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Raw)
.exclude_sources(std::iter::once("netease".to_owned()).collect::<FxHashSet<String>>())
.gap_ms(30 * 60_000)
.retention(Retention::Forever)
.build();
let (recorder, handle) = StatsRecorder::spawn(store.clone(), params);
recorder.event(mineral_stats::StatsEvent::Behavior {
actor: Actor::User,
event: mineral_stats::BehaviorEvent::Search {
query: Some("q".to_owned()),
query_hash: "h".to_owned(),
kind: mineral_stats::SearchTargetKind::Song,
source: SourceKind::NETEASE,
page: 0,
result_count: Some(5),
outcome: mineral_stats::SearchOutcome::Ok,
},
});
recorder.event(mineral_stats::StatsEvent::Behavior {
actor: Actor::User,
event: mineral_stats::BehaviorEvent::Seek {
song: SongId::new(SourceKind::NETEASE, "1"),
from_ms: 0,
to_ms: 5000,
},
});
recorder.event(mineral_stats::StatsEvent::Behavior {
actor: Actor::User,
event: mineral_stats::BehaviorEvent::VolumeChange {
from_pct: 50,
to_pct: 60,
},
});
drop(recorder);
handle.await?;
assert_eq!(store.status().await?.events, 1, "仅无来源的音量事件落库");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn play_context_search_query_redacted_by_mode() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let params = StatsParams::builder()
.level(Level::Full)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Off)
.exclude_sources(FxHashSet::default())
.gap_ms(30 * 60_000)
.retention(Retention::Forever)
.build();
let (recorder, handle) = StatsRecorder::spawn(store.clone(), params);
let mut p = pending(1000);
p.context = QueueContext::Search {
query: Some("敏感词".to_owned()),
};
recorder.play_started(p);
recorder.play_ended(FinishReason::Eof, 3000);
drop(recorder);
handle.await?;
let contexts = store.top_contexts(0..i64::MAX, None, 0, 10).await?;
let first = contexts
.first()
.ok_or_else(|| color_eyre::eyre::eyre!("期望一条语境"))?;
assert_eq!(first.kind, "search", "kind 保留");
assert_eq!(first.reference, None, "Off 档搜索词不落 context_ref");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn enrich_play_audio_fills_format() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, handle) = StatsRecorder::spawn(store.clone(), full_params());
recorder.play_started(pending(1000));
recorder.enrich_play_audio(
mineral_stats::PlayAudioSnapshot {
audio_format: Some(mineral_model::AudioFormat::Flac),
bitrate_bps: Some(900_000),
quality: Some(mineral_model::BitRate::Lossless),
bit_depth: Some(16),
substituted: false,
},
mineral_protocol::PlaybackOrigin::Remote,
);
recorder.play_ended(FinishReason::Eof, 3000);
drop(recorder);
handle.await?;
let dist = store.distributions(0..i64::MAX).await?;
assert!(
dist.by_format
.iter()
.any(|s| s.value == "flac" && s.plays == 1),
"by_format 应含 flac:{:?}",
dist.by_format
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn off_level_records_nothing() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, handle) = StatsRecorder::spawn(store.clone(), super::off_params());
recorder.play_started(pending(1000));
recorder.play_ended(FinishReason::Eof, 3000);
drop(recorder);
handle.await?;
assert_eq!(store.totals(0..i64::MAX).await?.plays, 0, "off 档零写入");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retention_days_prunes_ancient_keeps_recent() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
seed_play(&store, 1000).await?; let now_ms = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis(),
)?;
seed_play(&store, now_ms).await?; let params = Arc::new(ArcSwap::from_pointee(days_params(1)));
super::run_retention(&store, ¶ms).await;
let plays = store.recent_plays(0..i64::MAX, None, 10).await?;
assert_eq!(plays.len(), 1, "只应留下窗口内的一行");
let kept = plays
.first()
.ok_or_else(|| color_eyre::eyre::eyre!("期望留下一行"))?;
assert_eq!(kept.started_at, now_ms, "留下的是此刻那行");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retention_forever_prunes_nothing() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
seed_play(&store, 1000).await?; let params = Arc::new(ArcSwap::from_pointee(full_params()));
super::run_retention(&store, ¶ms).await;
assert_eq!(
store.totals(0..i64::MAX).await?.plays,
1,
"Forever 一行不裁"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn daemon_lifecycle_flush_is_a_barrier() -> color_eyre::Result<()> {
use mineral_stats::AudioBackend;
let (_dir, store) = temp_store().await?;
let (recorder, _handle) = StatsRecorder::spawn(store.clone(), full_params());
recorder.daemon_started(AudioBackend::Null, true);
recorder.daemon_stopped();
recorder.flush().await;
assert_eq!(
store.status().await?.events,
2,
"start + stop 两条应在 flush 返回前落库"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_query_mode_gates_recording() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let off = StatsParams::builder()
.level(Level::Full)
.collect(FxHashMap::default())
.search_queries(SearchQueryMode::Off)
.exclude_sources(FxHashSet::default())
.gap_ms(30 * 60_000)
.retention(Retention::Forever)
.build();
let (rec_off, h_off) = StatsRecorder::spawn(store.clone(), off);
rec_off.record_search(
Actor::User,
"q",
mineral_stats::SearchTargetKind::Song,
SourceKind::NETEASE,
0,
Some(3),
mineral_stats::SearchOutcome::Ok,
);
drop(rec_off);
h_off.await?;
assert_eq!(store.status().await?.events, 0, "Off 档搜索整条不记");
let (rec_raw, h_raw) = StatsRecorder::spawn(store.clone(), full_params());
rec_raw.record_search(
Actor::User,
"q",
mineral_stats::SearchTargetKind::Song,
SourceKind::NETEASE,
0,
Some(3),
mineral_stats::SearchOutcome::Ok,
);
drop(rec_raw);
h_raw.await?;
assert_eq!(store.status().await?.events, 1, "Raw 档记一条搜索");
Ok(())
}
#[tokio::test]
async fn channel_full_increments_drop_counter() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, _actor) = StatsRecorder::spawn(store, full_params());
let cap = i64::try_from(super::CHANNEL_CAPACITY)?;
let overflow = 100_i64;
for _ in 0..(cap + overflow) {
recorder.play_ended(FinishReason::Stop, 0);
}
assert_eq!(
recorder.dropped_count(),
u64::try_from(overflow)?,
"超出容量的都计入丢弃计数"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_settles_in_play_pending_as_stop() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, actor) = StatsRecorder::spawn(store.clone(), full_params());
recorder.play_started(pending(1000)); recorder.flush().await; recorder.shutdown().await; actor.await?; let plays = store.recent_plays(0..i64::MAX, None, 10).await?;
assert_eq!(plays.len(), 1, "在播 pending 结算为一行");
let row = plays
.first()
.ok_or_else(|| color_eyre::eyre::eyre!("期望结算行"))?;
assert_eq!(row.finish_reason, FinishReason::Stop, "结算为 stop");
assert_eq!(row.started_at, 1000, "结算的是在播那首(起播时刻不变)");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_without_pending_writes_nothing() -> color_eyre::Result<()> {
let (_dir, store) = temp_store().await?;
let (recorder, actor) = StatsRecorder::spawn(store.clone(), full_params());
recorder.shutdown().await;
actor.await?;
assert_eq!(store.totals(0..i64::MAX).await?.plays, 0, "无在播行不造行");
Ok(())
}
}