mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.3.11
噪音数据历史记录,引入数据库
This commit is contained in:
146
LanMountainDesktop/Services/AppDatabaseService.cs
Normal file
146
LanMountainDesktop/Services/AppDatabaseService.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppDatabaseServiceFactory
|
||||
{
|
||||
private static readonly Lazy<AppDatabaseService> SharedService = new(
|
||||
() => new AppDatabaseService(),
|
||||
isThreadSafe: true);
|
||||
|
||||
public static AppDatabaseService CreateDefault()
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AppDatabaseService
|
||||
{
|
||||
private readonly object _schemaSyncRoot = new();
|
||||
private readonly string _databasePath;
|
||||
private bool _schemaInitialized;
|
||||
|
||||
public AppDatabaseService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_databasePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var connection = new SqliteConnection($"Data Source={_databasePath};Cache=Shared;Mode=ReadWriteCreate");
|
||||
connection.Open();
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureSchema(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private void EnsureSchema(SqliteConnection connection)
|
||||
{
|
||||
if (_schemaInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_schemaSyncRoot)
|
||||
{
|
||||
if (_schemaInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_session_reports (
|
||||
session_id TEXT NOT NULL PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
started_at_utc_ms INTEGER NOT NULL,
|
||||
ended_at_utc_ms INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
avg_score REAL NOT NULL,
|
||||
slice_count INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
updated_at_utc_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_session_reports_ended_at
|
||||
ON study_session_reports(ended_at_utc_ms DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_noise_slices (
|
||||
timeline_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
start_at_utc_ms INTEGER NOT NULL,
|
||||
end_at_utc_ms INTEGER NOT NULL,
|
||||
source_type INTEGER NOT NULL,
|
||||
session_id TEXT NULL,
|
||||
score REAL NOT NULL,
|
||||
avg_db REAL NOT NULL,
|
||||
p95_db REAL NOT NULL,
|
||||
p50_dbfs REAL NOT NULL,
|
||||
over_ratio_dbfs REAL NOT NULL,
|
||||
segment_count INTEGER NOT NULL,
|
||||
slice_json TEXT NOT NULL,
|
||||
created_at_utc_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_end_at
|
||||
ON study_noise_slices(end_at_utc_ms DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_study_noise_slices_source_time
|
||||
ON study_noise_slices(source_type, end_at_utc_ms DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
session_id TEXT NOT NULL PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
started_at_utc_ms INTEGER NOT NULL,
|
||||
ended_at_utc_ms INTEGER NULL,
|
||||
status TEXT NOT NULL,
|
||||
score REAL NULL,
|
||||
payload_json TEXT NULL,
|
||||
created_at_utc_ms INTEGER NOT NULL,
|
||||
updated_at_utc_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_sessions_started_at
|
||||
ON attendance_sessions(started_at_utc_ms DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_events (
|
||||
event_id TEXT NOT NULL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
occurred_at_utc_ms INTEGER NOT NULL,
|
||||
payload_json TEXT NULL,
|
||||
created_at_utc_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES attendance_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_events_session_time
|
||||
ON attendance_events(session_id, occurred_at_utc_ms DESC);
|
||||
""";
|
||||
command.ExecuteNonQuery();
|
||||
_schemaInitialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
130
LanMountainDesktop/Services/AttendanceDataStore.cs
Normal file
130
LanMountainDesktop/Services/AttendanceDataStore.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class AttendanceDataStore
|
||||
{
|
||||
private readonly AppDatabaseService _databaseService;
|
||||
|
||||
public AttendanceDataStore(AppDatabaseService? databaseService = null)
|
||||
{
|
||||
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||
}
|
||||
|
||||
public IReadOnlyList<AttendanceSessionRecord> LoadSessions(int limit = 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = limit > 0
|
||||
? """
|
||||
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
|
||||
FROM attendance_sessions
|
||||
ORDER BY started_at_utc_ms DESC
|
||||
LIMIT $limit;
|
||||
"""
|
||||
: """
|
||||
SELECT session_id, label, started_at_utc_ms, ended_at_utc_ms, status, score, payload_json
|
||||
FROM attendance_sessions
|
||||
ORDER BY started_at_utc_ms DESC;
|
||||
""";
|
||||
if (limit > 0)
|
||||
{
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
}
|
||||
|
||||
var sessions = new List<AttendanceSessionRecord>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sessionId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var label = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||
var startedAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2));
|
||||
DateTimeOffset? endedAt = reader.IsDBNull(3)
|
||||
? null
|
||||
: DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3));
|
||||
var status = reader.IsDBNull(4) ? string.Empty : reader.GetString(4);
|
||||
double? score = reader.IsDBNull(5) ? null : reader.GetDouble(5);
|
||||
var payload = reader.IsDBNull(6) ? null : reader.GetString(6);
|
||||
|
||||
sessions.Add(new AttendanceSessionRecord(
|
||||
SessionId: sessionId,
|
||||
Label: label,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: endedAt,
|
||||
Status: status,
|
||||
Score: score,
|
||||
PayloadJson: payload));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<AttendanceSessionRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
public void UpsertSession(AttendanceSessionRecord record)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO attendance_sessions(
|
||||
session_id,
|
||||
label,
|
||||
started_at_utc_ms,
|
||||
ended_at_utc_ms,
|
||||
status,
|
||||
score,
|
||||
payload_json,
|
||||
created_at_utc_ms,
|
||||
updated_at_utc_ms)
|
||||
VALUES(
|
||||
$sessionId,
|
||||
$label,
|
||||
$startedAtUtcMs,
|
||||
$endedAtUtcMs,
|
||||
$status,
|
||||
$score,
|
||||
$payloadJson,
|
||||
$createdAtUtcMs,
|
||||
$updatedAtUtcMs)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
started_at_utc_ms = excluded.started_at_utc_ms,
|
||||
ended_at_utc_ms = excluded.ended_at_utc_ms,
|
||||
status = excluded.status,
|
||||
score = excluded.score,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||
""";
|
||||
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
command.Parameters.AddWithValue("$sessionId", record.SessionId);
|
||||
command.Parameters.AddWithValue("$label", record.Label);
|
||||
command.Parameters.AddWithValue("$startedAtUtcMs", record.StartedAt.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$endedAtUtcMs", record.EndedAt?.ToUnixTimeMilliseconds() ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("$status", record.Status);
|
||||
command.Parameters.AddWithValue("$score", record.Score ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("$payloadJson", record.PayloadJson ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -40,9 +41,25 @@ public interface IStudyAnalyticsService : IDisposable
|
||||
|
||||
void ClearLastSessionReport();
|
||||
|
||||
bool SelectSessionReport(string sessionId);
|
||||
|
||||
bool RenameSessionReport(string sessionId, string label);
|
||||
|
||||
bool DeleteSessionReport(string sessionId);
|
||||
|
||||
IReadOnlyList<NoiseSliceTimelineEntry> QueryNoiseSliceTimeline(
|
||||
DateTimeOffset? startAt = null,
|
||||
DateTimeOffset? endAt = null,
|
||||
int limit = 720,
|
||||
bool includeRealtimeSlices = true,
|
||||
bool includeSessionSlices = true);
|
||||
|
||||
void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null);
|
||||
|
||||
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
|
||||
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||
|
||||
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||
}
|
||||
|
||||
|
||||
@@ -358,6 +358,8 @@ internal sealed class SessionAccumulator
|
||||
|
||||
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
||||
|
||||
public string? CurrentSessionId => _sessionId;
|
||||
|
||||
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
||||
{
|
||||
if (IsRunning)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
@@ -18,7 +20,9 @@ public static class StudyAnalyticsServiceFactory
|
||||
|
||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
private const int MaxPersistedSessionReports = 120;
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly StudyDataStore _studyDataStore = new();
|
||||
private readonly IAudioRecorderService _audioRecorderService;
|
||||
private readonly Timer _samplingTimer;
|
||||
private readonly NoiseFramePipeline _pipeline;
|
||||
@@ -31,6 +35,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private NoiseRealtimePoint? _latestRealtime;
|
||||
private NoiseSliceSummary? _latestSlice;
|
||||
private StudySessionReport? _lastSessionReport;
|
||||
private readonly List<StudySessionReport> _sessionHistory = [];
|
||||
private string? _selectedSessionReportId;
|
||||
private string _lastError = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -53,6 +59,9 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = audioSnapshot.LastError;
|
||||
}
|
||||
|
||||
RestoreSessionHistoryFromDatabaseLocked();
|
||||
UpdateDataModeLocked();
|
||||
}
|
||||
|
||||
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
@@ -172,7 +181,10 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||
_lastSessionReport = finishedReport;
|
||||
if (finishedReport is not null)
|
||||
{
|
||||
UpsertSessionReportLocked(finishedReport, selectReport: true);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateDataModeLocked();
|
||||
@@ -214,6 +226,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
}
|
||||
|
||||
_lastSessionReport = null;
|
||||
_selectedSessionReportId = null;
|
||||
PersistSessionHistoryLocked();
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
started = true;
|
||||
@@ -237,7 +251,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSessionReport = report;
|
||||
UpsertSessionReportLocked(report, selectReport: true);
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
@@ -273,6 +287,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
_lastSessionReport = null;
|
||||
_selectedSessionReportId = null;
|
||||
PersistSessionHistoryLocked();
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
@@ -280,6 +296,144 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
public bool SelectSessionReport(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFindSessionReportLocked(sessionId, out var report))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_selectedSessionReportId = report.SessionId;
|
||||
_lastSessionReport = report;
|
||||
PersistSessionHistoryLocked();
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RenameSessionReport(string sessionId, string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedLabel = string.IsNullOrWhiteSpace(label)
|
||||
? string.Empty
|
||||
: label.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedLabel))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var updated = _sessionHistory[index] with { Label = normalizedLabel };
|
||||
_sessionHistory[index] = updated;
|
||||
if (string.Equals(_selectedSessionReportId, updated.SessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_lastSessionReport = updated;
|
||||
}
|
||||
|
||||
PersistSessionHistoryLocked();
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DeleteSessionReport(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = _sessionHistory[index];
|
||||
_sessionHistory.RemoveAt(index);
|
||||
|
||||
if (string.Equals(_selectedSessionReportId, removed.SessionId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_selectedSessionReportId = null;
|
||||
_lastSessionReport = null;
|
||||
}
|
||||
|
||||
PersistSessionHistoryLocked();
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NoiseSliceTimelineEntry> QueryNoiseSliceTimeline(
|
||||
DateTimeOffset? startAt = null,
|
||||
DateTimeOffset? endAt = null,
|
||||
int limit = 720,
|
||||
bool includeRealtimeSlices = true,
|
||||
bool includeSessionSlices = true)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
}
|
||||
|
||||
return _studyDataStore.LoadNoiseSliceTimeline(
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
limit: limit,
|
||||
includeRealtimeSlices: includeRealtimeSlices,
|
||||
includeSessionSlices: includeSessionSlices);
|
||||
}
|
||||
|
||||
public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
}
|
||||
|
||||
_studyDataStore.ClearNoiseSliceTimeline(olderThan);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
@@ -299,6 +453,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
NoiseSliceSummary? closedSlice = null;
|
||||
string? closedSliceSessionId = null;
|
||||
var closedSliceSourceType = NoiseSliceSourceType.Realtime;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
||||
@@ -350,6 +506,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
_sessionAccumulator.AddSlice(closedSlice);
|
||||
closedSliceSessionId = _sessionAccumulator.CurrentSessionId;
|
||||
closedSliceSourceType = NoiseSliceSourceType.Session;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +517,14 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
if (closedSlice is not null)
|
||||
{
|
||||
_studyDataStore.AppendNoiseSlice(
|
||||
slice: closedSlice,
|
||||
sessionId: closedSliceSessionId,
|
||||
sourceType: closedSliceSourceType);
|
||||
}
|
||||
|
||||
if (snapshot is not null)
|
||||
{
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
@@ -425,6 +591,18 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
|
||||
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
||||
{
|
||||
var historyEntries = _sessionHistory
|
||||
.OrderByDescending(report => report.EndedAt)
|
||||
.Select(report => new StudySessionHistoryEntry(
|
||||
SessionId: report.SessionId,
|
||||
Label: report.Label,
|
||||
StartedAt: report.StartedAt,
|
||||
EndedAt: report.EndedAt,
|
||||
Duration: report.Duration,
|
||||
AverageScore: report.Metrics.AvgScore,
|
||||
SliceCount: report.Metrics.SliceCount))
|
||||
.ToArray();
|
||||
|
||||
return new StudyAnalyticsSnapshot(
|
||||
State: _state,
|
||||
StreamStatus: _streamStatus,
|
||||
@@ -435,6 +613,8 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
||||
Session: _sessionAccumulator.GetSnapshot(now),
|
||||
LastSessionReport: _lastSessionReport,
|
||||
SelectedSessionReportId: _selectedSessionReportId,
|
||||
SessionHistory: historyEntries,
|
||||
LastError: _lastError);
|
||||
}
|
||||
|
||||
@@ -490,4 +670,93 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpsertSessionReportLocked(StudySessionReport report, bool selectReport)
|
||||
{
|
||||
var index = FindSessionReportIndexLocked(report.SessionId);
|
||||
if (index >= 0)
|
||||
{
|
||||
_sessionHistory[index] = report;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sessionHistory.Add(report);
|
||||
}
|
||||
|
||||
NormalizeSessionHistoryLocked();
|
||||
|
||||
if (selectReport)
|
||||
{
|
||||
_selectedSessionReportId = report.SessionId;
|
||||
_lastSessionReport = report;
|
||||
}
|
||||
|
||||
PersistSessionHistoryLocked();
|
||||
}
|
||||
|
||||
private bool TryFindSessionReportLocked(string sessionId, out StudySessionReport report)
|
||||
{
|
||||
var index = FindSessionReportIndexLocked(sessionId);
|
||||
if (index >= 0)
|
||||
{
|
||||
report = _sessionHistory[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
report = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private int FindSessionReportIndexLocked(string sessionId)
|
||||
{
|
||||
return _sessionHistory.FindIndex(report =>
|
||||
string.Equals(report.SessionId, sessionId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private void NormalizeSessionHistoryLocked()
|
||||
{
|
||||
_sessionHistory.Sort((left, right) => right.EndedAt.CompareTo(left.EndedAt));
|
||||
if (_sessionHistory.Count > MaxPersistedSessionReports)
|
||||
{
|
||||
_sessionHistory.RemoveRange(MaxPersistedSessionReports, _sessionHistory.Count - MaxPersistedSessionReports);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistSessionHistoryLocked()
|
||||
{
|
||||
var orderedReports = _sessionHistory
|
||||
.OrderByDescending(report => report.EndedAt)
|
||||
.Take(MaxPersistedSessionReports)
|
||||
.ToList();
|
||||
_studyDataStore.ReplaceSessionReports(orderedReports);
|
||||
_studyDataStore.SetSelectedSessionReportId(_selectedSessionReportId);
|
||||
}
|
||||
|
||||
private void RestoreSessionHistoryFromDatabaseLocked()
|
||||
{
|
||||
_sessionHistory.Clear();
|
||||
|
||||
var restored = _studyDataStore.LoadSessionReports(MaxPersistedSessionReports)
|
||||
.Where(report =>
|
||||
!string.IsNullOrWhiteSpace(report.SessionId) &&
|
||||
report.EndedAt >= report.StartedAt)
|
||||
.GroupBy(report => report.SessionId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.OrderByDescending(report => report.EndedAt)
|
||||
.Take(MaxPersistedSessionReports);
|
||||
_sessionHistory.AddRange(restored);
|
||||
|
||||
_selectedSessionReportId = _studyDataStore.GetSelectedSessionReportId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_selectedSessionReportId) &&
|
||||
TryFindSessionReportLocked(_selectedSessionReportId, out var selectedReport))
|
||||
{
|
||||
_lastSessionReport = selectedReport;
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedSessionReportId = null;
|
||||
_lastSessionReport = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
450
LanMountainDesktop/Services/StudyDataStore.cs
Normal file
450
LanMountainDesktop/Services/StudyDataStore.cs
Normal file
@@ -0,0 +1,450 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class StudyDataStore
|
||||
{
|
||||
private const string SelectedSessionReportIdMetaKey = "study.selected_session_report_id";
|
||||
private const int DefaultNoiseSliceCapacity = 50000;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly AppDatabaseService _databaseService;
|
||||
|
||||
public StudyDataStore(AppDatabaseService? databaseService = null)
|
||||
{
|
||||
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||
}
|
||||
|
||||
public IReadOnlyList<StudySessionReport> LoadSessionReports(int limit = 120)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = limit > 0
|
||||
? """
|
||||
SELECT report_json
|
||||
FROM study_session_reports
|
||||
ORDER BY ended_at_utc_ms DESC
|
||||
LIMIT $limit;
|
||||
"""
|
||||
: """
|
||||
SELECT report_json
|
||||
FROM study_session_reports
|
||||
ORDER BY ended_at_utc_ms DESC;
|
||||
""";
|
||||
if (limit > 0)
|
||||
{
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
}
|
||||
|
||||
var reports = new List<StudySessionReport>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.IsDBNull(0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (report is not null)
|
||||
{
|
||||
reports.Add(report);
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<StudySessionReport>();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetSessionReport(string sessionId, out StudySessionReport report)
|
||||
{
|
||||
report = null!;
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT report_json
|
||||
FROM study_session_reports
|
||||
WHERE session_id = $sessionId
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$sessionId", sessionId.Trim());
|
||||
|
||||
var json = command.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
|
||||
if (parsed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
report = parsed;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReplaceSessionReports(IReadOnlyList<StudySessionReport> reports)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
using (var clearCommand = connection.CreateCommand())
|
||||
{
|
||||
clearCommand.Transaction = transaction;
|
||||
clearCommand.CommandText = "DELETE FROM study_session_reports;";
|
||||
clearCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
for (var i = 0; i < reports.Count; i++)
|
||||
{
|
||||
InsertOrUpdateSessionReport(connection, transaction, reports[i]);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetSelectedSessionReportId()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT value
|
||||
FROM app_meta
|
||||
WHERE key = $key
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||
var value = command.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSelectedSessionReportId(string? sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
using var deleteCommand = connection.CreateCommand();
|
||||
deleteCommand.CommandText = "DELETE FROM app_meta WHERE key = $key;";
|
||||
deleteCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||
deleteCommand.ExecuteNonQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
using var upsertCommand = connection.CreateCommand();
|
||||
upsertCommand.CommandText = """
|
||||
INSERT INTO app_meta(key, value)
|
||||
VALUES ($key, $value)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
||||
""";
|
||||
upsertCommand.Parameters.AddWithValue("$key", SelectedSessionReportIdMetaKey);
|
||||
upsertCommand.Parameters.AddWithValue("$value", sessionId.Trim());
|
||||
upsertCommand.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendNoiseSlice(NoiseSliceSummary slice, string? sessionId, NoiseSliceSourceType sourceType)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var sliceJson = JsonSerializer.Serialize(slice, JsonOptions);
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO study_noise_slices(
|
||||
start_at_utc_ms,
|
||||
end_at_utc_ms,
|
||||
source_type,
|
||||
session_id,
|
||||
score,
|
||||
avg_db,
|
||||
p95_db,
|
||||
p50_dbfs,
|
||||
over_ratio_dbfs,
|
||||
segment_count,
|
||||
slice_json,
|
||||
created_at_utc_ms)
|
||||
VALUES(
|
||||
$startAtUtcMs,
|
||||
$endAtUtcMs,
|
||||
$sourceType,
|
||||
$sessionId,
|
||||
$score,
|
||||
$avgDb,
|
||||
$p95Db,
|
||||
$p50Dbfs,
|
||||
$overRatioDbfs,
|
||||
$segmentCount,
|
||||
$sliceJson,
|
||||
$createdAtUtcMs);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$startAtUtcMs", slice.StartAt.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$endAtUtcMs", slice.EndAt.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$sourceType", (int)sourceType);
|
||||
command.Parameters.AddWithValue("$sessionId", string.IsNullOrWhiteSpace(sessionId) ? (object)DBNull.Value : sessionId.Trim());
|
||||
command.Parameters.AddWithValue("$score", slice.Score);
|
||||
command.Parameters.AddWithValue("$avgDb", slice.Display.AvgDb);
|
||||
command.Parameters.AddWithValue("$p95Db", slice.Display.P95Db);
|
||||
command.Parameters.AddWithValue("$p50Dbfs", slice.Raw.P50Dbfs);
|
||||
command.Parameters.AddWithValue("$overRatioDbfs", slice.Raw.OverRatioDbfs);
|
||||
command.Parameters.AddWithValue("$segmentCount", Math.Max(0, slice.Raw.SegmentCount));
|
||||
command.Parameters.AddWithValue("$sliceJson", sliceJson);
|
||||
command.Parameters.AddWithValue("$createdAtUtcMs", nowUtcMs);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var trimCommand = connection.CreateCommand())
|
||||
{
|
||||
trimCommand.Transaction = transaction;
|
||||
trimCommand.CommandText = """
|
||||
DELETE FROM study_noise_slices
|
||||
WHERE timeline_id NOT IN (
|
||||
SELECT timeline_id
|
||||
FROM study_noise_slices
|
||||
ORDER BY end_at_utc_ms DESC
|
||||
LIMIT $limit
|
||||
);
|
||||
""";
|
||||
trimCommand.Parameters.AddWithValue("$limit", DefaultNoiseSliceCapacity);
|
||||
trimCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<NoiseSliceTimelineEntry> LoadNoiseSliceTimeline(
|
||||
DateTimeOffset? startAt = null,
|
||||
DateTimeOffset? endAt = null,
|
||||
int limit = 720,
|
||||
bool includeRealtimeSlices = true,
|
||||
bool includeSessionSlices = true)
|
||||
{
|
||||
if (!includeRealtimeSlices && !includeSessionSlices)
|
||||
{
|
||||
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
var whereParts = new List<string>();
|
||||
if (startAt is not null)
|
||||
{
|
||||
whereParts.Add("end_at_utc_ms >= $startAtUtcMs");
|
||||
command.Parameters.AddWithValue("$startAtUtcMs", startAt.Value.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
if (endAt is not null)
|
||||
{
|
||||
whereParts.Add("start_at_utc_ms <= $endAtUtcMs");
|
||||
command.Parameters.AddWithValue("$endAtUtcMs", endAt.Value.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
if (includeRealtimeSlices != includeSessionSlices)
|
||||
{
|
||||
var sourceType = includeSessionSlices
|
||||
? (int)NoiseSliceSourceType.Session
|
||||
: (int)NoiseSliceSourceType.Realtime;
|
||||
whereParts.Add("source_type = $sourceType");
|
||||
command.Parameters.AddWithValue("$sourceType", sourceType);
|
||||
}
|
||||
|
||||
var whereClause = whereParts.Count == 0
|
||||
? string.Empty
|
||||
: $"WHERE {string.Join(" AND ", whereParts)}";
|
||||
var normalizedLimit = Math.Clamp(limit, 1, DefaultNoiseSliceCapacity);
|
||||
|
||||
command.CommandText = $"""
|
||||
SELECT timeline_id, source_type, session_id, slice_json
|
||||
FROM study_noise_slices
|
||||
{whereClause}
|
||||
ORDER BY end_at_utc_ms DESC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$limit", normalizedLimit);
|
||||
|
||||
var entries = new List<NoiseSliceTimelineEntry>(Math.Min(normalizedLimit, 2048));
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var timelineId = reader.GetInt64(0);
|
||||
var sourceTypeRaw = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
|
||||
var sourceType = sourceTypeRaw == (int)NoiseSliceSourceType.Session
|
||||
? NoiseSliceSourceType.Session
|
||||
: NoiseSliceSourceType.Realtime;
|
||||
var sessionId = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
if (reader.IsDBNull(3))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sliceJson = reader.GetString(3);
|
||||
if (string.IsNullOrWhiteSpace(sliceJson))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var slice = JsonSerializer.Deserialize<NoiseSliceSummary>(sliceJson, JsonOptions);
|
||||
if (slice is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new NoiseSliceTimelineEntry(
|
||||
TimelineId: timelineId,
|
||||
SourceType: sourceType,
|
||||
SessionId: sessionId,
|
||||
Slice: slice));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<NoiseSliceTimelineEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearNoiseSliceTimeline(DateTimeOffset? olderThan = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = _databaseService.OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
if (olderThan is null)
|
||||
{
|
||||
command.CommandText = "DELETE FROM study_noise_slices;";
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = "DELETE FROM study_noise_slices WHERE end_at_utc_ms <= $olderThanUtcMs;";
|
||||
command.Parameters.AddWithValue("$olderThanUtcMs", olderThan.Value.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep runtime resilient when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private static void InsertOrUpdateSessionReport(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
StudySessionReport report)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||
var nowUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO study_session_reports(
|
||||
session_id,
|
||||
label,
|
||||
started_at_utc_ms,
|
||||
ended_at_utc_ms,
|
||||
duration_ms,
|
||||
avg_score,
|
||||
slice_count,
|
||||
report_json,
|
||||
updated_at_utc_ms)
|
||||
VALUES (
|
||||
$sessionId,
|
||||
$label,
|
||||
$startedAtUtcMs,
|
||||
$endedAtUtcMs,
|
||||
$durationMs,
|
||||
$avgScore,
|
||||
$sliceCount,
|
||||
$reportJson,
|
||||
$updatedAtUtcMs)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
started_at_utc_ms = excluded.started_at_utc_ms,
|
||||
ended_at_utc_ms = excluded.ended_at_utc_ms,
|
||||
duration_ms = excluded.duration_ms,
|
||||
avg_score = excluded.avg_score,
|
||||
slice_count = excluded.slice_count,
|
||||
report_json = excluded.report_json,
|
||||
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$sessionId", report.SessionId);
|
||||
command.Parameters.AddWithValue("$label", report.Label);
|
||||
command.Parameters.AddWithValue("$startedAtUtcMs", report.StartedAt.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$endedAtUtcMs", report.EndedAt.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$durationMs", (long)Math.Max(0, report.Duration.TotalMilliseconds));
|
||||
command.Parameters.AddWithValue("$avgScore", report.Metrics.AvgScore);
|
||||
command.Parameters.AddWithValue("$sliceCount", report.Metrics.SliceCount);
|
||||
command.Parameters.AddWithValue("$reportJson", json);
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtcMs);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user