Files
Ez2Lazer/osu.Game/LAsEzExtensions/Analysis/EzAnalysisPersistentStore.cs
LA 0b9f9f70d6 主要为代码质量更新
1. 匹配新版按钮控件的自动宽度写法

2. 统一Ez日志写入方向

3.移除历史修改:缓存启用mod列表,切换mod时保持通用mod开启状态

4.代码格式化、

5.修改文件名称表意,更直观
2026-03-12 19:29:55 +08:00

1026 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.LAsEzExtensions.Configuration;
namespace osu.Game.LAsEzExtensions.Analysis
{
/// <summary>
/// 本地持久化的 mania analysis 存储。
///
/// 目标:对齐官方“有版本号、可增量补齐”的后台预处理体验,但不写回主谱面库的 Realmclient.realm
/// 以降低误操作/迁移导致主库损坏的风险。
///
/// 存储键BeatmapInfo.IDGuid+ BeatmapInfo.HashSHA-256
/// - 只要 beatmap 内容变化Hash 变化),对应条目会自动失效并重算。
/// - AnalysisVersion 用于你这边算法变更时整体失效。
///
/// 注意:此处使用 SQLite而不是额外 Realm 文件),因为向 osu.Game 程序集新增 RealmObject 类型
/// 会改变 client.realm 的 schema 并要求迁移;而 SQLite 独立文件更安全、易恢复。
/// </summary>
public class EzAnalysisPersistentStore
{
/// <summary>
/// 持久化总开关(默认关闭):未来考虑是否允许用户通过配置关闭此功能以避免额外的磁盘读写。
/// </summary>
public static bool Enabled = true;
public static readonly string DATABASE_FILENAME = $@"mania-analysis_v{ANALYSIS_VERSION}.sqlite";
// 手动维护:算法/序列化格式变更时递增。版本发生变化时,会强制重算所有已存条目。
// 注意:此版本号与 osu! 官方服务器端的版本号无关,仅用于本地持久化存储的失效控制。
// 注意:更新版本号后,务必通过注释保存旧版本的变更记录,方便日后排查问题。
// v2: 初始版本,包含 kps_list_json, column_counts_json
// v3: 添加 hold_note_counts_json 字段分离普通note和长按note统计
// v4: 添加 beatmap_md5 校验字段kps_list_json 仅保存用于 UI 的下采样曲线(<=256 点)。
// v5: 删除scratchText存储改为动态计算。数据库可兼容不升版。
public const int ANALYSIS_VERSION = 5;
private static readonly string[] allowed_columns =
{
"beatmap_id",
"beatmap_hash",
"beatmap_md5",
"analysis_version",
"average_kps",
"max_kps",
"kps_list_json",
"xxy_sr",
"column_counts_json",
"hold_note_counts_json",
"last_updated"
};
private readonly Storage storage;
private readonly object initLock = new object();
private bool initialised;
private string dbPath = string.Empty;
private record PendingWrite(BeatmapInfo Beatmap, EzAnalysisResult Analysis, long Timestamp);
private readonly ConcurrentDictionary<Guid, PendingWrite> pendingWrites = new ConcurrentDictionary<Guid, PendingWrite>();
private CancellationTokenSource? writeCts;
private Task? backgroundWriterTask;
// Old versions earlier than v3 may not have sufficient data to safely upgrade without recomputation.
// v3 introduced hold note counts, which are relied upon by parts of the UI.
private const int min_inplace_upgrade_version = 3;
public EzAnalysisPersistentStore(Storage storage)
{
this.storage = storage;
}
public void Initialise()
{
if (!Enabled)
return;
lock (initLock)
{
if (initialised)
return;
dbPath = storage.GetFullPath(DATABASE_FILENAME, true);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
// If this is a new versioned DB file, attempt to clone from the latest previous version to avoid
// forcing a full recompute (when changes are only schema/serialization related).
tryClonePreviousDatabaseIfMissing();
Logger.Log($"EzManiaAnalysisPersistentStore path: {dbPath}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Important);
using var connection = openConnection();
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA temp_store=MEMORY;
";
cmd.ExecuteNonQuery();
}
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mania_analysis (
beatmap_id TEXT PRIMARY KEY,
beatmap_hash TEXT NOT NULL,
beatmap_md5 TEXT NOT NULL,
analysis_version INTEGER NOT NULL,
average_kps REAL NOT NULL,
max_kps REAL NOT NULL,
kps_list_json TEXT NOT NULL,
xxy_sr REAL NULL,
column_counts_json TEXT NOT NULL,
hold_note_counts_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mania_analysis_version ON mania_analysis(analysis_version);
";
cmd.ExecuteNonQuery();
}
// 从旧版本平滑升级如果缺少列则补齐SQLite 不支持 IF NOT EXISTS 语法的 ADD COLUMN
if (!hasColumn(connection, "mania_analysis", "kps_list_json"))
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN kps_list_json TEXT NOT NULL DEFAULT '[]';";
cmd.ExecuteNonQuery();
}
if (!hasColumn(connection, "mania_analysis", "hold_note_counts_json"))
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN hold_note_counts_json TEXT NOT NULL DEFAULT '{}';";
cmd.ExecuteNonQuery();
}
if (!hasColumn(connection, "mania_analysis", "beatmap_md5"))
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN beatmap_md5 TEXT NOT NULL DEFAULT '';";
cmd.ExecuteNonQuery();
}
// Store the current analysis version as meta (informational).
setMeta(connection, "analysis_version", ANALYSIS_VERSION.ToString(CultureInfo.InvariantCulture));
// Ensure last_updated column exists for bookkeeping (minimal schema extension).
if (!hasColumn(connection, "mania_analysis", "last_updated"))
{
using var add = connection.CreateCommand();
add.CommandText = "ALTER TABLE mania_analysis ADD COLUMN last_updated INTEGER NOT NULL DEFAULT 0;";
add.ExecuteNonQuery();
}
// 检查并清理不需要的列(处理版本升级时删除的字段)
cleanupUnrecognizedColumns(connection);
initialised = true;
// Start background writer for pending writes.
startBackgroundWriter();
}
catch (Exception e)
{
// 如果数据库损坏/无法打开:不影响游戏运行;尝试备份并重新创建。
Logger.Error(e, "EzManiaAnalysisPersistentStore failed to initialise; recreating database.");
try
{
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{
string backup = dbPath + ".bak";
File.Copy(dbPath, backup, overwrite: true);
File.Delete(dbPath);
}
}
catch
{
// ignored
}
// Second attempt.
initialised = false;
Initialise();
}
}
}
public bool TryGet(BeatmapInfo beatmap, out EzAnalysisResult result)
{
result = default;
if (!Enabled)
return false;
try
{
Initialise();
// If we have a pending write for this beatmap, prefer that (latest in-memory result).
if (pendingWrites.TryGetValue(beatmap.ID, out var pending))
{
// Basic validation against hash to avoid returning stale pending for different beatmap content.
if (string.Equals(pending.Beatmap.Hash, beatmap.Hash, StringComparison.Ordinal))
{
result = pending.Analysis;
return true;
}
// otherwise fall through to DB lookup
}
using var connection = openConnection();
string storedHash;
string storedMd5;
int storedVersion;
double averageKps;
double maxKps;
string kpsListJson;
double? xxySr;
string columnCountsJson;
string holdNoteCountsJson;
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"
SELECT beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json
FROM mania_analysis
WHERE beatmap_id = $id
LIMIT 1;
";
cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString());
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return false;
storedHash = reader.GetString(0);
storedMd5 = reader.GetString(1);
storedVersion = reader.GetInt32(2);
averageKps = reader.GetDouble(3);
maxKps = reader.GetDouble(4);
kpsListJson = reader.GetString(5);
xxySr = reader.IsDBNull(6) ? null : reader.GetDouble(6);
columnCountsJson = reader.GetString(7);
holdNoteCountsJson = reader.GetString(8);
}
if (!string.Equals(storedHash, beatmap.Hash, StringComparison.Ordinal))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] stored_hash mismatch for {beatmap.ID}: stored={storedHash} runtime={beatmap.Hash}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
return false;
}
// md5 validation:
// - If stored md5 is empty (older versions), accept hash match and upgrade in-place.
// - If stored md5 is present, require it to match.
if (!string.IsNullOrEmpty(storedMd5) && !string.Equals(storedMd5, beatmap.MD5Hash, StringComparison.Ordinal))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] stored_md5 mismatch for {beatmap.ID}: stored={storedMd5} runtime={beatmap.MD5Hash}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
return false;
}
// If the stored version is newer than this build, ignore and let caller recompute.
if (storedVersion > ANALYSIS_VERSION)
return false;
var columnCounts = JsonSerializer.Deserialize<Dictionary<int, int>>(columnCountsJson) ?? new Dictionary<int, int>();
var holdNoteCounts = JsonSerializer.Deserialize<Dictionary<int, int>>(holdNoteCountsJson) ?? new Dictionary<int, int>();
var kpsList = JsonSerializer.Deserialize<List<double>>(kpsListJson) ?? new List<double>();
// Allow in-place upgrade of compatible older entries to avoid full recompute.
// If an older version is not compatible, treat it as a miss.
if (storedVersion != ANALYSIS_VERSION)
{
if (!canUpgradeInPlace(storedVersion))
return false;
bool mutated = string.IsNullOrEmpty(storedMd5);
// v4: store md5 for extra safety (hash already guards real content).
// v4: kps_list_json is UI graph only; keep it capped for perf.
if (kpsList.Count > OptimizedBeatmapCalculator.DEFAULT_KPS_GRAPH_POINTS)
{
kpsList = OptimizedBeatmapCalculator.DownsampleToFixedCount(kpsList, OptimizedBeatmapCalculator.DEFAULT_KPS_GRAPH_POINTS);
mutated = true;
}
if (mutated)
{
// Persist the upgraded row (without recomputing analysis).
writeUpgradedRow(connection, beatmap, averageKps, maxKps, kpsList, xxySr, columnCounts, holdNoteCounts);
}
}
var summary = new KpsSummary(averageKps, maxKps, kpsList);
var details = new ManiaDetails(columnCounts, holdNoteCounts, xxySr);
result = new EzAnalysisResult(summary, details);
// Validate the analysis result to ensure it's reasonable
if (!isValidAnalysisResult(result))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] Invalid analysis result for {beatmap.ID}, ignoring cached data.", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
return false;
}
// missingRequiredXxySr = requireXxySr && xxySr == null;
return true;
}
catch (Exception e)
{
Logger.Error(e, "EzManiaAnalysisPersistentStore TryGet failed.", Ez2ConfigManager.LOGGER_NAME);
return false;
}
}
/// <summary>
/// 对比新计算结果和 SQLite 中的旧数据,如果有差异则更新。
/// 主要场景:
/// - xxysr 从 null 补算成有值mania 模式的谱面被重新计算)
/// - KPS 数据有显著变化(算法修复等)
/// 工作机制:
/// - 如果 stored 数据不存在,直接存储新数据
/// - 如果 stored xxysr == null 而 computed 有值,说明需要补充 xxysr更新
/// - 如果都是 xxysr == null说明是非 mania 模式数据,比较 KPS 数据是否相同
/// </summary>
public void StoreIfDifferent(BeatmapInfo beatmap, EzAnalysisResult analysis)
{
if (!Enabled)
return;
// Validate the analysis result before storing
if (!isValidAnalysisResult(analysis))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] Refusing to store invalid analysis result for {beatmap.ID}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
return;
}
// 跳过空谱面no notes- 不需要存储和管理
if (analysis.Details.ColumnCounts.Count == 0)
return;
try
{
Initialise();
using var connection = openConnection();
// 尝试从 SQLite 读取旧数据
if (!tryGetRawData(connection, beatmap, out var storedAnalysis))
{
// 缓存不存在,直接存储
Store(beatmap, analysis);
return;
}
// 对比两个结果是否有差异
if (hasDifference(storedAnalysis, analysis))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] Data difference detected for {beatmap.ID}, updating SQLite.", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
Store(beatmap, analysis);
}
}
catch (Exception e)
{
Logger.Error(e, "EzManiaAnalysisPersistentStore StoreIfDifferent failed.");
}
}
/// <summary>
/// 从数据库读取原始数据(不验证 hash/version
/// </summary>
private bool tryGetRawData(SqliteConnection connection, BeatmapInfo beatmap, out EzAnalysisResult result)
{
result = default;
try
{
// Check pending writes first to ensure we return the freshest data even if not flushed.
if (pendingWrites.TryGetValue(beatmap.ID, out var pending))
{
if (string.Equals(pending.Beatmap.Hash, beatmap.Hash, StringComparison.Ordinal))
{
result = pending.Analysis;
return true;
}
}
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"
SELECT average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json
FROM mania_analysis
WHERE beatmap_id = $id
LIMIT 1;
";
cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString());
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return false;
double averageKps = reader.GetDouble(0);
double maxKps = reader.GetDouble(1);
string kpsListJson = reader.GetString(2);
double? xxySr = reader.IsDBNull(3) ? null : reader.GetDouble(3);
string columnCountsJson = reader.GetString(4);
string holdNoteCountsJson = reader.GetString(5);
var columnCounts = JsonSerializer.Deserialize<Dictionary<int, int>>(columnCountsJson) ?? new Dictionary<int, int>();
var holdNoteCounts = JsonSerializer.Deserialize<Dictionary<int, int>>(holdNoteCountsJson) ?? new Dictionary<int, int>();
var kpsList = JsonSerializer.Deserialize<List<double>>(kpsListJson) ?? new List<double>();
var summary = new KpsSummary(averageKps, maxKps, kpsList);
var details = new ManiaDetails(columnCounts, holdNoteCounts, xxySr);
result = new EzAnalysisResult(summary, details);
return true;
}
}
catch
{
return false;
}
}
/// <summary>
/// 比较两个分析结果是否有差异。
/// 关键字段xxysr, averageKps, maxKps, ColumnCounts, HoldNoteCounts
/// </summary>
private bool hasDifference(EzAnalysisResult stored, EzAnalysisResult computed)
{
// 检查 xxysr 差异(最重要)
// 如果 stored 是 null 而 computed 有值,必须更新
if (!stored.Details.XxySr.HasValue && computed.Details.XxySr.HasValue)
return true;
// 如果都有值,比较数值是否相同
if (stored.Details.XxySr.HasValue && computed.Details.XxySr.HasValue)
{
if (!stored.Details.XxySr.Value.Equals(computed.Details.XxySr.Value))
return true;
}
// 检查 KPS 相关数据
if (!stored.Summary.AverageKps.Equals(computed.Summary.AverageKps) || !stored.Summary.MaxKps.Equals(computed.Summary.MaxKps))
return true;
// 检查列统计
if (stored.Details.ColumnCounts.Count != computed.Details.ColumnCounts.Count)
return true;
foreach (var kvp in computed.Details.ColumnCounts)
{
if (!stored.Details.ColumnCounts.TryGetValue(kvp.Key, out int storedCount) || storedCount != kvp.Value)
return true;
}
// 检查长按统计
if (stored.Details.HoldNoteCounts.Count != computed.Details.HoldNoteCounts.Count)
return true;
foreach (var kvp in computed.Details.HoldNoteCounts)
{
if (!stored.Details.HoldNoteCounts.TryGetValue(kvp.Key, out int storedCount) || storedCount != kvp.Value)
return true;
}
return false;
}
public void Store(BeatmapInfo beatmap, EzAnalysisResult analysis)
{
if (!Enabled)
return;
// Validate the analysis result before storing
if (!isValidAnalysisResult(analysis))
{
Logger.Log($"[EzManiaAnalysisPersistentStore] Refusing to store invalid analysis result for {beatmap.ID}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Debug);
return;
}
// 跳过空谱面no notes- 不需要存储和管理
if (analysis.Details.ColumnCounts.Count == 0)
return;
// Enqueue pending write and return quickly. Background writer will flush to SQLite.
try
{
Initialise();
var pending = new PendingWrite(beatmap, analysis, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
pendingWrites[beatmap.ID] = pending;
}
catch (Exception e)
{
// If enqueue fails for some reason, fallback to synchronous write to avoid data loss.
Logger.Error(e, "EzManiaAnalysisPersistentStore enqueue Store failed, falling back to sync write.");
try
{
// synchronous fallback
Initialise();
using var connection = openConnection();
writePendingEntryToConnection(connection, beatmap, analysis);
}
catch (Exception ex)
{
Logger.Error(ex, "EzManiaAnalysisPersistentStore Store fallback failed.");
}
}
}
public IReadOnlyList<Guid> GetBeatmapsNeedingRecompute(IEnumerable<(Guid id, string hash)> beatmaps)
=> GetBeatmapsNeedingRecompute(beatmaps, progress: null);
public IReadOnlyList<Guid> GetBeatmapsNeedingRecompute(IEnumerable<(Guid id, string hash)> beatmaps, Action<int, int>? progress)
{
if (!Enabled)
return Array.Empty<Guid>();
try
{
Initialise();
var beatmapList = beatmaps as IList<(Guid id, string hash)> ?? beatmaps.ToList();
// 读出已有条目id -> (hash, version))。
Dictionary<Guid, (string hash, int version)> existing = new Dictionary<Guid, (string hash, int version)>();
using (var connection = openConnection())
using (var cmd = connection.CreateCommand())
{
cmd.CommandText = @"SELECT beatmap_id, beatmap_hash, analysis_version FROM mania_analysis;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (!Guid.TryParse(reader.GetString(0), out var id))
continue;
string storedHash = reader.GetString(1);
int storedVersion = reader.GetInt32(2);
existing[id] = (storedHash, storedVersion);
}
}
List<Guid> needing = new List<Guid>();
int processed = 0;
int total = beatmapList.Count;
foreach (var (id, hash) in beatmapList)
{
processed++;
if (processed == 1 || processed % 200 == 0)
progress?.Invoke(processed, total);
if (!existing.TryGetValue(id, out var row))
{
needing.Add(id);
continue;
}
// Only force recompute on:
// - missing entries
// - hash mismatch (beatmap changed)
// - versions which cannot be upgraded in-place.
// Version bumps which are only schema/serialization should be upgraded lazily on TryGet().
if (!string.Equals(row.hash, hash, StringComparison.Ordinal) || !canUpgradeInPlace(row.version) || row.version > ANALYSIS_VERSION)
needing.Add(id);
}
progress?.Invoke(total, total);
// 可选清理:删除已不存在的条目(避免无限增长)。
// 这里用 HashSet 做 membership 判断,避免每条都查库。
HashSet<Guid> live = beatmapList.Select(b => b.id).ToHashSet();
var toDelete = existing.Keys.Where(id => !live.Contains(id)).ToList();
if (toDelete.Count > 0)
{
using var connection = openConnection();
using var transaction = connection.BeginTransaction();
using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = "DELETE FROM mania_analysis WHERE beatmap_id = $id;";
var idParam = cmd.CreateParameter();
idParam.ParameterName = "$id";
cmd.Parameters.Add(idParam);
foreach (var id in toDelete)
{
idParam.Value = id.ToString();
cmd.ExecuteNonQuery();
}
transaction.Commit();
}
return needing;
}
catch (Exception e)
{
Logger.Error(e, "EzManiaAnalysisPersistentStore GetBeatmapsNeedingRecompute failed.");
return Array.Empty<Guid>();
}
}
private SqliteConnection openConnection()
{
// 这里每次操作打开一个连接,避免跨线程复用连接导致的问题。
var connection = new SqliteConnection($"Data Source={dbPath};Cache=Shared;Mode=ReadWriteCreate");
connection.Open();
return connection;
}
private void setMeta(SqliteConnection connection, string key, string value)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO meta(key, value)
VALUES($k, $v)
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
";
cmd.Parameters.AddWithValue("$k", key);
cmd.Parameters.AddWithValue("$v", value);
cmd.ExecuteNonQuery();
}
private static bool canUpgradeInPlace(int storedVersion)
=> storedVersion >= min_inplace_upgrade_version && storedVersion <= ANALYSIS_VERSION;
private void tryClonePreviousDatabaseIfMissing()
{
if (string.IsNullOrEmpty(dbPath))
return;
if (File.Exists(dbPath))
return;
string? dir = Path.GetDirectoryName(dbPath);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
return;
// Find the latest previous DB file (by version suffix) which we can potentially upgrade from.
// Even if it contains older rows, we will still validate per-row and decide upgrade vs recompute.
string? bestCandidate = null;
int bestVersion = -1;
foreach (string file in Directory.EnumerateFiles(dir, "mania-analysis_v*.sqlite", SearchOption.TopDirectoryOnly))
{
if (string.Equals(file, dbPath, StringComparison.OrdinalIgnoreCase))
continue;
if (!tryParseDatabaseVersion(file, out int version))
continue;
if (version >= ANALYSIS_VERSION)
continue;
if (version > bestVersion)
{
bestVersion = version;
bestCandidate = file;
}
}
if (bestCandidate == null)
return;
try
{
File.Copy(bestCandidate, dbPath);
Logger.Log($"[EzManiaAnalysisPersistentStore] Cloned DB from v{bestVersion} to v{ANALYSIS_VERSION}: {Path.GetFileName(bestCandidate)} -> {Path.GetFileName(dbPath)}", Ez2ConfigManager.LOGGER_NAME, LogLevel.Important);
}
catch (Exception e)
{
// If cloning fails, we simply fall back to creating a fresh DB and recomputing as needed.
Logger.Error(e, "[EzManiaAnalysisPersistentStore] Failed to clone previous DB; falling back to fresh database.", Ez2ConfigManager.LOGGER_NAME);
}
}
private static bool tryParseDatabaseVersion(string filePath, out int version)
{
version = 0;
string name = Path.GetFileName(filePath);
const string prefix = "mania-analysis_v";
const string suffix = ".sqlite";
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
return false;
string number = name.Substring(prefix.Length, name.Length - prefix.Length - suffix.Length);
return int.TryParse(number, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
}
private static void writeUpgradedRow(SqliteConnection connection,
BeatmapInfo beatmap,
double averageKps,
double maxKps,
IReadOnlyList<double> kpsList,
double? xxySr,
IReadOnlyDictionary<int, int> columnCounts,
IReadOnlyDictionary<int, int> holdNoteCounts)
{
string kpsListJson = JsonSerializer.Serialize(kpsList);
string columnCountsJson = JsonSerializer.Serialize(columnCounts);
string holdNoteCountsJson = JsonSerializer.Serialize(holdNoteCounts);
using var update = connection.CreateCommand();
update.CommandText = @"
UPDATE mania_analysis
SET beatmap_md5 = $md5,
analysis_version = $version,
kps_list_json = $kps_list_json,
xxy_sr = $xxy_sr,
column_counts_json = $column_counts_json,
hold_note_counts_json = $hold_note_counts_json
WHERE beatmap_id = $id;
";
update.Parameters.AddWithValue("$id", beatmap.ID.ToString());
update.Parameters.AddWithValue("$md5", beatmap.MD5Hash);
update.Parameters.AddWithValue("$version", ANALYSIS_VERSION);
update.Parameters.AddWithValue("$kps_list_json", kpsListJson);
update.Parameters.AddWithValue("$xxy_sr", xxySr is null ? DBNull.Value : xxySr.Value);
update.Parameters.AddWithValue("$column_counts_json", columnCountsJson);
update.Parameters.AddWithValue("$hold_note_counts_json", holdNoteCountsJson);
update.ExecuteNonQuery();
}
private void startBackgroundWriter()
{
if (backgroundWriterTask != null)
return;
writeCts = new CancellationTokenSource();
backgroundWriterTask = Task.Run(() => backgroundWriterLoop(writeCts.Token));
}
private async Task backgroundWriterLoop(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(500, token).ConfigureAwait(false);
if (pendingWrites.IsEmpty)
continue;
var batch = pendingWrites.ToArray();
try
{
using var connection = openConnection();
using var transaction = connection.BeginTransaction();
foreach (var kv in batch)
{
var id = kv.Key;
var pw = kv.Value;
try
{
writePendingEntryToConnection(connection, pw.Beatmap, pw.Analysis, transaction);
// Only remove if the pending entry we wrote is still the latest.
if (pendingWrites.TryGetValue(id, out var latest) && latest.Timestamp == pw.Timestamp)
pendingWrites.TryRemove(id, out _);
}
catch (Exception e)
{
Logger.Error(e, "EzManiaAnalysisPersistentStore background write failed for entry.");
}
}
transaction.Commit();
}
catch (Exception e)
{
// Log and continue; writer will retry on next loop.
Logger.Error(e, "EzManiaAnalysisPersistentStore background batch write failed.");
}
}
}
catch (OperationCanceledException)
{
// expected on cancellation
}
catch (Exception e)
{
Logger.Error(e, "EzManiaAnalysisPersistentStore background writer crashed.");
}
}
private void writePendingEntryToConnection(SqliteConnection connection, BeatmapInfo beatmap, EzAnalysisResult analysis, SqliteTransaction? transaction = null)
{
using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
string kpsListJson = JsonSerializer.Serialize(analysis.Summary.KpsList);
string columnCountsJson = JsonSerializer.Serialize(analysis.Details.ColumnCounts);
string holdNoteCountsJson = JsonSerializer.Serialize(analysis.Details.HoldNoteCounts);
cmd.CommandText = @"
INSERT INTO mania_analysis(
beatmap_id,
beatmap_hash,
beatmap_md5,
analysis_version,
average_kps,
max_kps,
kps_list_json,
xxy_sr,
column_counts_json,
hold_note_counts_json
)
VALUES(
$id,
$hash,
$md5,
$version,
$avg,
$max,
$kps,
$xxy,
$cols,
$holds
)
ON CONFLICT(beatmap_id) DO UPDATE SET
beatmap_hash = excluded.beatmap_hash,
beatmap_md5 = excluded.beatmap_md5,
analysis_version = excluded.analysis_version,
average_kps = excluded.average_kps,
max_kps = excluded.max_kps,
kps_list_json = excluded.kps_list_json,
xxy_sr = excluded.xxy_sr,
column_counts_json = excluded.column_counts_json,
hold_note_counts_json = excluded.hold_note_counts_json;
";
cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString());
cmd.Parameters.AddWithValue("$hash", beatmap.Hash);
cmd.Parameters.AddWithValue("$md5", beatmap.MD5Hash);
cmd.Parameters.AddWithValue("$version", ANALYSIS_VERSION);
cmd.Parameters.AddWithValue("$avg", analysis.Summary.AverageKps);
cmd.Parameters.AddWithValue("$max", analysis.Summary.MaxKps);
cmd.Parameters.AddWithValue("$kps", kpsListJson);
if (analysis.Details.XxySr.HasValue)
cmd.Parameters.AddWithValue("$xxy", analysis.Details.XxySr.Value);
else
cmd.Parameters.AddWithValue("$xxy", DBNull.Value);
cmd.Parameters.AddWithValue("$cols", columnCountsJson);
cmd.Parameters.AddWithValue("$holds", holdNoteCountsJson);
cmd.ExecuteNonQuery();
// Update last_updated if column exists
try
{
if (hasColumn(connection, "mania_analysis", "last_updated"))
{
using var upd = connection.CreateCommand();
upd.Transaction = transaction;
upd.CommandText = "UPDATE mania_analysis SET last_updated = $ts WHERE beatmap_id = $id";
upd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
upd.Parameters.AddWithValue("$id", beatmap.ID.ToString());
upd.ExecuteNonQuery();
}
}
catch
{
// ignore last_updated failures
}
}
private void cleanupUnrecognizedColumns(SqliteConnection connection)
{
var existingColumns = getTableColumns(connection, "mania_analysis");
var unrecognizedColumns = existingColumns.Where(c => !allowed_columns.Contains(c, StringComparer.OrdinalIgnoreCase)).ToList();
if (unrecognizedColumns.Count == 0)
return;
// 重建表,删除不识别的列
Logger.Log($"[EzManiaAnalysisPersistentStore] Found unrecognized columns: {string.Join(", ", unrecognizedColumns)}; rebuilding table.", Ez2ConfigManager.LOGGER_NAME);
rebuildTableWithoutUnrecognizedColumns(connection, unrecognizedColumns);
}
private List<string> getTableColumns(SqliteConnection connection, string tableName)
{
var columns = new List<string>();
using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({tableName});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
columns.Add(reader.GetString(1)); // name
}
return columns;
}
private void rebuildTableWithoutUnrecognizedColumns(SqliteConnection connection, List<string> unrecognizedColumns)
{
// 创建临时表,只包含允许的列
using var createTempCmd = connection.CreateCommand();
createTempCmd.CommandText = @"
CREATE TABLE mania_analysis_temp (
beatmap_id TEXT PRIMARY KEY,
beatmap_hash TEXT NOT NULL,
beatmap_md5 TEXT NOT NULL,
analysis_version INTEGER NOT NULL,
average_kps REAL NOT NULL,
max_kps REAL NOT NULL,
kps_list_json TEXT NOT NULL,
xxy_sr REAL NULL,
column_counts_json TEXT NOT NULL,
hold_note_counts_json TEXT NOT NULL
);
";
createTempCmd.ExecuteNonQuery();
// 复制数据,只复制允许的列
using var insertCmd = connection.CreateCommand();
insertCmd.CommandText = @"
INSERT INTO mania_analysis_temp (beatmap_id, beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json)
SELECT beatmap_id, beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json
FROM mania_analysis;
";
insertCmd.ExecuteNonQuery();
// 删除旧表
using var dropCmd = connection.CreateCommand();
dropCmd.CommandText = "DROP TABLE mania_analysis;";
dropCmd.ExecuteNonQuery();
// 重命名临时表
using var renameCmd = connection.CreateCommand();
renameCmd.CommandText = "ALTER TABLE mania_analysis_temp RENAME TO mania_analysis;";
renameCmd.ExecuteNonQuery();
// 重新创建索引
using var indexCmd = connection.CreateCommand();
indexCmd.CommandText = "CREATE INDEX IF NOT EXISTS idx_mania_analysis_version ON mania_analysis(analysis_version);";
indexCmd.ExecuteNonQuery();
// 清理数据库文件大小
using var vacuumCmd = connection.CreateCommand();
vacuumCmd.CommandText = "VACUUM;";
vacuumCmd.ExecuteNonQuery();
}
private bool hasColumn(SqliteConnection connection, string tableName, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({tableName});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
string name = reader.GetString(1);
if (string.Equals(name, columnName, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// Validates that the analysis result contains reasonable values.
/// </summary>
private static bool isValidAnalysisResult(EzAnalysisResult result)
{
if (result.Details.XxySr.HasValue && (double.IsNaN(result.Details.XxySr.Value) || double.IsInfinity(result.Details.XxySr.Value)))
return false;
return true;
}
}
}