Files
LanMountainDesktop/LanMountainDesktop/Services/StudyDataStore.cs

476 lines
17 KiB
C#

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;
private readonly Action<string>? _logger;
public StudyDataStore(AppDatabaseService? databaseService = null, Action<string>? logger = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
_logger = logger;
}
private void Log(string message)
{
_logger?.Invoke($"[StudyDataStore] {message}");
}
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;
}
try
{
var report = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
if (report is not null)
{
reports.Add(report);
}
}
catch (JsonException ex)
{
Log($"Failed to deserialize session report: {ex.Message}");
}
}
return reports;
}
catch (Exception ex)
{
Log($"Failed to load session reports: {ex.Message}");
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))
{
Log($"Session report not found for id: {sessionId}");
return false;
}
var parsed = JsonSerializer.Deserialize<StudySessionReport>(json, JsonOptions);
if (parsed is null)
{
Log($"Failed to deserialize session report for id: {sessionId}");
return false;
}
report = parsed;
return true;
}
catch (JsonException ex)
{
Log($"JSON deserialization error for session {sessionId}: {ex.Message}");
return false;
}
catch (Exception ex)
{
Log($"Failed to get session report {sessionId}: {ex.Message}");
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 (Exception ex)
{
Log($"Failed to replace session reports: {ex.Message}");
}
}
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 (Exception ex)
{
Log($"Failed to get selected session report id: {ex.Message}");
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 (Exception ex)
{
Log($"Failed to set selected session report id: {ex.Message}");
}
}
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 (Exception ex)
{
Log($"Failed to append noise slice: {ex.Message}");
}
}
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 (Exception ex)
{
Log($"Failed to load noise slice timeline: {ex.Message}");
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 (Exception ex)
{
Log($"Failed to clear noise slice timeline: {ex.Message}");
}
}
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();
}
}