mirror of
https://github.com/SK-la/Ez2Lazer.git
synced 2026-03-13 11:20:28 +00:00
1. 匹配新版按钮控件的自动宽度写法 2. 统一Ez日志写入方向 3.移除历史修改:缓存启用mod列表,切换mod时保持通用mod开启状态 4.代码格式化、 5.修改文件名称表意,更直观
1026 lines
41 KiB
C#
1026 lines
41 KiB
C#
// 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 存储。
|
||
///
|
||
/// 目标:对齐官方“有版本号、可增量补齐”的后台预处理体验,但不写回主谱面库的 Realm(client.realm),
|
||
/// 以降低误操作/迁移导致主库损坏的风险。
|
||
///
|
||
/// 存储键:BeatmapInfo.ID(Guid)+ BeatmapInfo.Hash(SHA-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;
|
||
}
|
||
}
|
||
}
|