feat(player-stats): local listening history tab with heatmap and summaries (#849)

* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
cucadmuh
2026-05-22 14:07:38 +03:00
committed by GitHub
parent 7afddf7b84
commit 23f7ba02d6
49 changed files with 3059 additions and 19 deletions
+16 -7
View File
@@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
import {
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from './playListenSession';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
@@ -75,13 +81,15 @@ export type NormalizationStatePayload = {
targetLufs: number;
};
export function handleAudioPlaying(_duration: number): void {
export function handleAudioPlaying(duration: number): void {
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
// Tell the library scheduler to throttle bulk crawl while a stream
// is active (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('playing');
const track = usePlayerStore.getState().currentTrack;
if (track) {
void playListenSessionOpen(track, getPlaybackServerId(), duration);
}
}
export function handleAudioProgress(
@@ -142,6 +150,7 @@ export function handleAudioProgress(
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = displayTime / dur;
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
if (!progressUiDisabled) {
const nowLive = Date.now();
const live = getPlaybackProgressSnapshot();
@@ -311,16 +320,14 @@ export function handleAudioProgress(
}
export function handleAudioEnded(): void {
// Playback stopped — let the library scheduler resume normal crawl
// parallelism (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('idle');
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - getLastGaplessSwitchTime() < 600) {
return;
}
void playListenSessionFinalize('ended');
// Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) {
setIsAudioPaused(false);
@@ -392,6 +399,8 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (!nextTrack) return;
void playListenSessionOnTrackSwitched(nextTrack);
const switchServerId = getPlaybackServerId();
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
+187
View File
@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { onInvoke } from '../test/mocks/tauri';
import { useLibraryIndexStore } from './libraryIndexStore';
import {
_resetPlayListenSessionForTest,
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOpen,
resolveDurationSecHint,
} from './playListenSession';
import { onPlaySessionRecorded } from './playSessionRecorded';
vi.mock('../utils/library/libraryReady', () => ({
libraryIsReady: vi.fn(async () => true),
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: vi.fn(() => 'server-1'),
}));
import type { Track } from './playerStoreTypes';
const testTrack: Track = {
id: 't1',
title: 'A',
artist: 'B',
album: '',
albumId: '',
duration: 180,
};
describe('playListenSession', () => {
beforeEach(async () => {
_resetPlayListenSessionForTest();
useLibraryIndexStore.setState({
masterEnabled: true,
syncExcludedByServer: {},
});
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
usePlayerStore.setState({
currentRadio: null,
isPlaying: true,
currentTrack: testTrack,
});
usePreviewStore.setState({ previewingId: null });
onInvoke('library_record_play_session', () => undefined);
});
it('does not invoke when listenedSec <= 10', async () => {
await playListenSessionOpen(testTrack, 'server-1');
await playListenSessionOnProgress(5, false);
await playListenSessionFinalize('ended');
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('invokes once after >10s listened', async () => {
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({
trackId: 't1',
serverId: 'server-1',
endReason: 'ended',
durationSecHint: 180,
}),
}),
);
});
it('picks up engine duration from progress ticks', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentTrack: { ...testTrack, duration: 0 },
});
vi.useFakeTimers();
await playListenSessionOpen({ ...testTrack, duration: 0 }, 'server-1', 240);
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false, 240);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({ durationSecHint: 240 }),
}),
);
});
it('skips when index disabled', async () => {
useLibraryIndexStore.setState({ masterEnabled: false });
await playListenSessionOpen(testTrack, 'server-1');
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips preview playback', async () => {
const { usePreviewStore } = await import('./previewStore');
usePreviewStore.setState({ previewingId: 'preview-1' });
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips radio playback', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
});
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('does not accumulate listened time while paused or buffering', async () => {
const { usePlayerStore } = await import('./playerStore');
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
usePlayerStore.setState({ isPlaying: false });
await playListenSessionOnProgress(12, false);
vi.setSystemTime(Date.now() + 30_000);
await playListenSessionOnProgress(12, true);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips when library is not ready', async () => {
const { libraryIsReady } = await import('../utils/library/libraryReady');
vi.mocked(libraryIsReady).mockResolvedValueOnce(false);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('emits play-session-recorded after a persisted listen', async () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
unsub();
expect(listener).toHaveBeenCalledWith({
serverId: 'server-1',
trackId: 't1',
startedAtMs: expect.any(Number),
});
});
});
describe('resolveDurationSecHint', () => {
it('returns zero when no positive durations are available', () => {
expect(resolveDurationSecHint(null)).toBe(0);
expect(resolveDurationSecHint({ duration: 0 }, 0, undefined)).toBe(0);
});
it('prefers the largest finite positive hint', () => {
expect(resolveDurationSecHint({ duration: 180 }, 240, 200)).toBe(240);
});
});
+170
View File
@@ -0,0 +1,170 @@
import type { Track } from './playerStoreTypes';
import {
libraryRecordPlaySession,
type PlaySessionEndReason,
} from '../api/library';
import { libraryIsReady } from '../utils/library/libraryReady';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { emitPlaySessionRecorded } from './playSessionRecorded';
import { useLibraryIndexStore } from './libraryIndexStore';
const MIN_LISTENED_SEC = 10;
type OpenSession = {
serverId: string;
trackId: string;
startedAtMs: number;
listenedSec: number;
positionMaxSec: number;
durationSecHint: number;
lastTickMs: number;
recordingEnabled: boolean;
};
let open: OpenSession | null = null;
let finalizeInFlight: Promise<void> | null = null;
function clearOpen(): void {
open = null;
}
/** Best-known track length in seconds from player metadata and/or engine. */
export function resolveDurationSecHint(
track: Pick<Track, 'duration'> | null | undefined,
...extraSec: (number | undefined)[]
): number {
const values = [track?.duration, ...extraSec].filter(
(d): d is number => typeof d === 'number' && Number.isFinite(d) && d > 0,
);
if (values.length === 0) return 0;
return Math.round(Math.max(...values));
}
function noteDurationHint(durationSec?: number): void {
if (!open || !durationSec || durationSec <= 0) return;
const rounded = Math.round(durationSec);
if (rounded > open.durationSecHint) {
open.durationSecHint = rounded;
}
}
async function playerGateBlocks(): Promise<boolean> {
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
if (usePreviewStore.getState().previewingId) return true;
if (usePlayerStore.getState().currentRadio) return true;
return false;
}
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
if (await playerGateBlocks()) return false;
return libraryIsReady(serverId);
}
export async function playListenSessionOpen(
track: Track,
serverId: string,
engineDurationSec?: number,
): Promise<void> {
if (open && open.trackId === track.id && open.serverId === serverId) {
noteDurationHint(resolveDurationSecHint(track, engineDurationSec));
return;
}
await playListenSessionFinalize('skip');
const enabled = await recordingEnabledForServer(serverId);
if (!enabled) return;
open = {
serverId,
trackId: track.id,
startedAtMs: Date.now(),
listenedSec: 0,
positionMaxSec: 0,
durationSecHint: resolveDurationSecHint(track, engineDurationSec),
lastTickMs: Date.now(),
recordingEnabled: true,
};
}
export async function playListenSessionOnProgress(
currentTime: number,
buffering: boolean,
durationSecHint?: number,
): Promise<void> {
if (!open?.recordingEnabled) return;
noteDurationHint(durationSecHint);
const { usePlayerStore } = await import('./playerStore');
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (track?.id === open.trackId) {
noteDurationHint(resolveDurationSecHint(track, durationSecHint));
}
if (!store.isPlaying || buffering) {
open.lastTickMs = Date.now();
return;
}
const now = Date.now();
const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000);
open.lastTickMs = now;
open.listenedSec += deltaSec;
if (Number.isFinite(currentTime) && currentTime > open.positionMaxSec) {
open.positionMaxSec = currentTime;
}
}
export async function playListenSessionFinalize(reason: PlaySessionEndReason): Promise<void> {
if (finalizeInFlight) {
await finalizeInFlight;
}
if (!open) return;
const session = open;
clearOpen();
if (!session.recordingEnabled || session.listenedSec <= MIN_LISTENED_SEC) {
return;
}
const { usePlayerStore } = await import('./playerStore');
const track = usePlayerStore.getState().currentTrack;
const durationSecHint = resolveDurationSecHint(
track?.id === session.trackId ? track : null,
session.durationSecHint,
);
finalizeInFlight = libraryRecordPlaySession({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
listenedSec: session.listenedSec,
positionMaxSec: session.positionMaxSec,
endReason: reason,
durationSecHint: durationSecHint > 0 ? durationSecHint : undefined,
})
.then(() => {
emitPlaySessionRecorded({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
});
})
.catch(() => undefined)
.finally(() => {
finalizeInFlight = null;
});
await finalizeInFlight;
}
export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise<void> {
const serverId = getPlaybackServerId();
await playListenSessionFinalize('switch');
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
}
/** Test-only reset */
export function _resetPlayListenSessionForTest(): void {
open = null;
finalizeInFlight = null;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { emitPlaySessionRecorded, onPlaySessionRecorded } from './playSessionRecorded';
describe('playSessionRecorded', () => {
it('notifies subscribers when a listen is persisted', () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
const detail = { serverId: 's1', trackId: 't1', startedAtMs: 123 };
emitPlaySessionRecorded(detail);
expect(listener).toHaveBeenCalledWith(detail);
unsub();
listener.mockClear();
emitPlaySessionRecorded(detail);
expect(listener).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
export type PlaySessionRecordedDetail = {
serverId: string;
trackId: string;
startedAtMs: number;
};
const EVENT_NAME = 'psysonic:play-session-recorded';
export function emitPlaySessionRecorded(detail: PlaySessionRecordedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<PlaySessionRecordedDetail>(EVENT_NAME, { detail }));
}
export function onPlaySessionRecorded(
listener: (detail: PlaySessionRecordedDetail) => void,
): () => void {
if (typeof window === 'undefined') return () => {};
const wrapped = (evt: Event) => {
const ce = evt as CustomEvent<PlaySessionRecordedDetail>;
if (!ce?.detail) return;
listener(ce.detail);
};
window.addEventListener(EVENT_NAME, wrapped as EventListener);
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
}
+3
View File
@@ -36,6 +36,7 @@ import {
import type { PlayerState, Track } from './playerStoreTypes';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
@@ -152,6 +153,8 @@ export function runPlayTrack(
return;
}
void playListenSessionFinalize('skip');
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
+2
View File
@@ -18,6 +18,7 @@ import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import i18n from '../i18n';
import { playListenSessionFinalize } from './playListenSession';
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
import { useLuckyMixStore } from './luckyMixStore';
import { showToast } from '../utils/ui/toast';
@@ -207,6 +208,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
},
clearQueue: () => {
void playListenSessionFinalize('stop');
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
clearSeekFallbackRetry();
+2
View File
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { setIsAudioPaused } from './engineState';
import type { PlayerState } from './playerStoreTypes';
import { flushQueueSyncToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pauseRadio, stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
@@ -28,6 +29,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
> {
return {
stop: () => {
void playListenSessionFinalize('stop');
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
stopRadio();