diff --git a/src/Core.Tests/DatabaseTests.cs b/src/Core.Tests/DatabaseTests.cs
index ff58d24..e0f6a94 100644
--- a/src/Core.Tests/DatabaseTests.cs
+++ b/src/Core.Tests/DatabaseTests.cs
@@ -66,7 +66,7 @@ public class DatabaseTests
{
// Act — 连续调用两次
Database.InitializeDatabase(dbName);
- var exception = Record.Exception(() => Database.InitializeDatabase(dbName));
+ Exception? exception = Record.Exception(() => Database.InitializeDatabase(dbName));
// Assert — 不应抛出异常
Assert.Null(exception);
@@ -83,4 +83,224 @@ public class DatabaseTests
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
+
+ #region 读取方法测试
+
+ private static AudioFileMeta CreateSampleMeta(int id = 0)
+ {
+ var guid = Guid.NewGuid().ToString("N");
+ return new AudioFileMeta
+ {
+ Id = id,
+ UniqueId = guid,
+ ShortId = "TEST",
+ Md5 = guid[..16],
+ Path = $"/test/path/{guid}.wav",
+ Filename = $"{guid}.wav",
+ Folder = "test",
+ Directory = "/test/path",
+ Duration = 2.5,
+ BitDepth = 24,
+ Channels = 2,
+ SampleRate = 48000,
+ Type = "WAV",
+ DateAdded = DateTime.UtcNow,
+ LastWriteTime = DateTime.UtcNow,
+ CreationTime = DateTime.UtcNow,
+ Description = "Test explosion sound",
+ Category = "SFX",
+ Keywords = "test,boom,explosion",
+ FxName = "Test Explosion Boom"
+ };
+ }
+
+ private static string GetDefaultDbPath() =>
+ Path.Combine(PreferencesManager.GetDefaultDatabasePath(), "default.rdb");
+
+ private static void DeleteTestEntries(params string[] uniqueIds)
+ {
+ using var conn = new SQLiteConnection($"Data Source={GetDefaultDbPath()};Version=3;");
+ conn.Open();
+ foreach (var uid in uniqueIds)
+ conn.Execute("DELETE FROM audio_files WHERE unique_id = @UniqueId", new { UniqueId = uid });
+ }
+
+ [Fact]
+ public void AddEntry_Then_GetEntryById_ShouldReturnRecord()
+ {
+ Database.InitializeDatabase();
+ var meta = CreateSampleMeta();
+ Assert.True(Database.AddEntry(meta));
+
+ try
+ {
+ var retrieved = Database.GetEntryById(meta.Id);
+ Assert.NotNull(retrieved);
+ Assert.Equal(meta.UniqueId, retrieved!.UniqueId);
+ Assert.Equal(meta.Filename, retrieved.Filename);
+ Assert.Equal(meta.Duration, retrieved.Duration);
+ }
+ finally
+ {
+ DeleteTestEntries(meta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void GetEntryById_NotFound_ShouldReturnNull()
+ {
+ Database.InitializeDatabase();
+ var result = Database.GetEntryById(int.MaxValue);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void AddEntry_Then_GetEntryByUniqueId_ShouldReturnRecord()
+ {
+ Database.InitializeDatabase();
+ var meta = CreateSampleMeta();
+ Database.AddEntry(meta);
+
+ try
+ {
+ var retrieved = Database.GetEntryByUniqueId(meta.UniqueId);
+ Assert.NotNull(retrieved);
+ Assert.Equal(meta.Md5, retrieved!.Md5);
+ }
+ finally
+ {
+ DeleteTestEntries(meta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void GetEntryByUniqueId_NotFound_ShouldReturnNull()
+ {
+ Database.InitializeDatabase();
+ var result = Database.GetEntryByUniqueId("nonexistent-guid");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void QueryEntries_ShouldFilterBySearchText()
+ {
+ Database.InitializeDatabase();
+ var meta = CreateSampleMeta();
+ meta.Description = "Unique underwater ambience loop";
+ meta.Keywords = "water,ocean,deep";
+ Database.AddEntry(meta);
+
+ try
+ {
+ var results = Database.QueryEntries(searchText: "underwater").ToList();
+ Assert.Single(results);
+ Assert.Equal(meta.UniqueId, results[0].UniqueId);
+
+ var empty = Database.QueryEntries(searchText: "zzz_nonexistent_zzz").ToList();
+ Assert.Empty(empty);
+ }
+ finally
+ {
+ DeleteTestEntries(meta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void QueryEntries_ShouldFilterByDuration()
+ {
+ Database.InitializeDatabase();
+ var shortMeta = CreateSampleMeta();
+ shortMeta.Duration = 1.0;
+ var longMeta = CreateSampleMeta();
+ longMeta.Duration = 10.0;
+
+ Database.AddEntries(new[] { shortMeta, longMeta });
+
+ try
+ {
+ var results = Database.QueryEntries(minDuration: 5.0).ToList();
+ Assert.Single(results);
+ Assert.Equal(longMeta.UniqueId, results[0].UniqueId);
+ }
+ finally
+ {
+ DeleteTestEntries(shortMeta.UniqueId, longMeta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void QueryEntries_ShouldSupportPagination()
+ {
+ Database.InitializeDatabase();
+ var entries = Enumerable.Range(0, 5).Select(_ => CreateSampleMeta()).ToList();
+ Database.AddEntries(entries);
+
+ try
+ {
+ var page = Database.QueryEntries(limit: 2, offset: 1).ToList();
+ Assert.Equal(2, page.Count);
+ }
+ finally
+ {
+ DeleteTestEntries(entries.Select(e => e.UniqueId).ToArray());
+ }
+ }
+
+ [Fact]
+ public void SearchEntries_ShouldFindByKeyword()
+ {
+ Database.InitializeDatabase();
+ var meta = CreateSampleMeta();
+ meta.Keywords = "magic,spell,fireball";
+ Database.AddEntry(meta);
+
+ try
+ {
+ var results = Database.SearchEntries("fireball").ToList();
+ Assert.Single(results);
+ }
+ finally
+ {
+ DeleteTestEntries(meta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void GetTotalCount_ShouldRespectSearchFilter()
+ {
+ Database.InitializeDatabase();
+ var meta = CreateSampleMeta();
+ var uniqueTag = $"UNIQUE_TAG_{meta.UniqueId[..8]}";
+ meta.Description = uniqueTag;
+ Database.AddEntry(meta);
+
+ try
+ {
+ var count = Database.GetTotalCount(searchText: uniqueTag);
+ Assert.Equal(1, count);
+ }
+ finally
+ {
+ DeleteTestEntries(meta.UniqueId);
+ }
+ }
+
+ [Fact]
+ public void AddEntries_ShouldReturnCorrectCount()
+ {
+ Database.InitializeDatabase();
+ var entries = Enumerable.Range(0, 3).Select(_ => CreateSampleMeta()).ToList();
+
+ try
+ {
+ var count = Database.AddEntries(entries);
+ Assert.Equal(3, count);
+ }
+ finally
+ {
+ DeleteTestEntries(entries.Select(e => e.UniqueId).ToArray());
+ }
+ }
+
+ #endregion
}
diff --git a/src/Core/Database.cs b/src/Core/Database.cs
index a17da2d..42b3bdb 100755
--- a/src/Core/Database.cs
+++ b/src/Core/Database.cs
@@ -6,6 +6,10 @@ namespace OCES.Resonance.Core;
public static class Database
{
+ static Database()
+ {
+ DefaultTypeMap.MatchNamesWithUnderscores = true;
+ }
///
/// 获取数据库连接
///
@@ -107,11 +111,11 @@ public static class Database
waveform BLOB
);
- CREATE INDEX idx_file_name on audio_files(filename);
- CREATE INDEX idx_md5 on audio_files(md5);
- CREATE INDEX idx_path on audio_files(path);
- CREATE INDEX idx_unique_id on audio_files(unique_id);
- CREATE INDEX idx_description on audio_files(description);
+ CREATE INDEX if not exists idx_file_name on audio_files(filename);
+ CREATE INDEX if not exists idx_md5 on audio_files(md5);
+ CREATE INDEX if not exists idx_path on audio_files(path);
+ CREATE INDEX if not exists idx_unique_id on audio_files(unique_id);
+ CREATE INDEX if not exists idx_description on audio_files(description);
""";
@@ -253,4 +257,211 @@ public static class Database
var count = connection.ExecuteScalar(sql, new { Md5 = md5, Path = filePath });
return count > 0;
}
+
+ #region 读取
+
+ // 详情查询公共列(含 BLOB),用于 GetEntryById / GetEntryByUniqueId
+ private const string DetailSelectColumns = @"
+ id, unique_id, short_id, md5, path, filename, folder, directory,
+ duration, bit_depth, channels, sample_rate, type,
+ date_added, original_modification_date AS last_write_time, origination_time AS creation_time,
+ bpm, frame_rate, timecode, description, category, subcategory,
+ cat_id, category_full, genre, style, mood, keywords, rating,
+ artist, composer, designer, recordist, publisher, manufacturer,
+ originator AS bwf_originator, originator_ref AS bwf_originator_ref, project_name, library, cd_title,
+ track_title, episode, scene, take, tape, cue_number, sync_point,
+ release_date, track_year, is_edited, is_split, location, [group],
+ markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
+ mic_perspective,
+ fx_name, channel_layout, disk_number, track_number,
+ artwork, waveform, bwf_umid,
+ user1, user2, user3, user4, user5, user6, user7, user8";
+
+ // 列表查询公共列(排除 BLOB:artwork / waveform / bwf_umid),减少数据传输
+ private const string ListSelectColumns = @"
+ id, unique_id, short_id, md5, path, filename, folder, directory,
+ duration, bit_depth, channels, sample_rate, type,
+ date_added, original_modification_date AS last_write_time, origination_time AS creation_time,
+ bpm, frame_rate, timecode, description, category, subcategory,
+ cat_id, category_full, genre, style, mood, keywords, rating,
+ artist, composer, designer, recordist, publisher, manufacturer,
+ originator AS bwf_originator, originator_ref AS bwf_originator_ref, project_name, library, cd_title,
+ track_title, episode, scene, take, tape, cue_number, sync_point,
+ release_date, track_year, is_edited, is_split, location, [group],
+ markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
+ mic_perspective,
+ fx_name, channel_layout, disk_number, track_number,
+ user1, user2, user3, user4, user5, user6, user7, user8";
+
+ // 排序白名单,防止 SQL 注入
+ private static readonly HashSet AllowedSortColumns = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "filename", "duration", "sample_rate", "channels",
+ "date_added", "rating", "type", "category", "id"
+ };
+
+ ///
+ /// 按主键取回单条记录(含 BLOB 字段:artwork / waveform / bwf_umid)
+ ///
+ public static AudioFileMeta? GetEntryById(int id)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ const string sql = $"SELECT {DetailSelectColumns} FROM audio_files WHERE id = @Id;";
+
+ return connection.QueryFirstOrDefault(sql, new { Id = id });
+ }
+
+ ///
+ /// 按全局唯一标识符取回单条记录(含 BLOB 字段)
+ ///
+ public static AudioFileMeta? GetEntryByUniqueId(string uniqueId)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ const string sql = $"SELECT {DetailSelectColumns} FROM audio_files WHERE unique_id = @UniqueId;";
+
+ return connection.QueryFirstOrDefault(sql, new { UniqueId = uniqueId });
+ }
+
+ ///
+ /// 多条件过滤查询(排除 BLOB 字段),支持分页和排序
+ ///
+ public static IEnumerable QueryEntries(
+ string? searchText = null,
+ double? minDuration = null,
+ double? maxDuration = null,
+ int? sampleRate = null,
+ int? channels = null,
+ string? category = null,
+ string? sortBy = null,
+ bool sortDescending = false,
+ int limit = 100,
+ int offset = 0)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ var where = new List();
+ var parameters = new DynamicParameters();
+
+ if (!string.IsNullOrWhiteSpace(searchText))
+ {
+ where.Add("(filename LIKE @SearchText OR description LIKE @SearchText OR keywords LIKE @SearchText OR fx_name LIKE @SearchText)");
+ parameters.Add("@SearchText", $"%{searchText}%");
+ }
+ if (minDuration.HasValue)
+ {
+ where.Add("duration >= @MinDuration");
+ parameters.Add("@MinDuration", minDuration.Value);
+ }
+ if (maxDuration.HasValue)
+ {
+ where.Add("duration <= @MaxDuration");
+ parameters.Add("@MaxDuration", maxDuration.Value);
+ }
+ if (sampleRate.HasValue)
+ {
+ where.Add("sample_rate = @SampleRate");
+ parameters.Add("@SampleRate", sampleRate.Value);
+ }
+ if (channels.HasValue)
+ {
+ where.Add("channels = @Channels");
+ parameters.Add("@Channels", channels.Value);
+ }
+ if (!string.IsNullOrWhiteSpace(category))
+ {
+ where.Add("category = @Category");
+ parameters.Add("@Category", category);
+ }
+
+ var whereClause = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
+
+ var orderBy = "ORDER BY date_added DESC";
+ if (!string.IsNullOrWhiteSpace(sortBy) && AllowedSortColumns.Contains(sortBy))
+ {
+ var dir = sortDescending ? "DESC" : "ASC";
+ orderBy = $"ORDER BY [{sortBy}] {dir}";
+ }
+
+ var sql = $"""
+ SELECT {ListSelectColumns}
+ FROM audio_files
+ {whereClause}
+ {orderBy}
+ LIMIT @Limit OFFSET @Offset;
+ """;
+
+ parameters.Add("@Limit", limit);
+ parameters.Add("@Offset", offset);
+
+ return connection.Query(sql, parameters);
+ }
+
+ ///
+ /// 文本搜索(filename / description / keywords / fx_name),排除 BLOB 字段
+ ///
+ public static IEnumerable SearchEntries(string query, int limit = 100, int offset = 0)
+ {
+ return QueryEntries(searchText: query, limit: limit, offset: offset);
+ }
+
+ ///
+ /// 返回匹配条件的总记录数(用于分页控件)
+ ///
+ public static int GetTotalCount(
+ string? searchText = null,
+ double? minDuration = null,
+ double? maxDuration = null,
+ int? sampleRate = null,
+ int? channels = null,
+ string? category = null)
+ {
+ using var connection = GetConnection();
+ connection.Open();
+
+ var where = new List();
+ var parameters = new DynamicParameters();
+
+ if (!string.IsNullOrWhiteSpace(searchText))
+ {
+ where.Add("(filename LIKE @SearchText OR description LIKE @SearchText OR keywords LIKE @SearchText OR fx_name LIKE @SearchText)");
+ parameters.Add("@SearchText", $"%{searchText}%");
+ }
+ if (minDuration.HasValue)
+ {
+ where.Add("duration >= @MinDuration");
+ parameters.Add("@MinDuration", minDuration.Value);
+ }
+ if (maxDuration.HasValue)
+ {
+ where.Add("duration <= @MaxDuration");
+ parameters.Add("@MaxDuration", maxDuration.Value);
+ }
+ if (sampleRate.HasValue)
+ {
+ where.Add("sample_rate = @SampleRate");
+ parameters.Add("@SampleRate", sampleRate.Value);
+ }
+ if (channels.HasValue)
+ {
+ where.Add("channels = @Channels");
+ parameters.Add("@Channels", channels.Value);
+ }
+ if (!string.IsNullOrWhiteSpace(category))
+ {
+ where.Add("category = @Category");
+ parameters.Add("@Category", category);
+ }
+
+ var whereClause = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
+ var sql = $"SELECT COUNT(*) FROM audio_files {whereClause};";
+
+ return connection.ExecuteScalar(sql, parameters);
+ }
+
+ #endregion
}
diff --git a/src/Resonance.sln.DotSettings.user b/src/Resonance.sln.DotSettings.user
index bfac62d..cd6f052 100755
--- a/src/Resonance.sln.DotSettings.user
+++ b/src/Resonance.sln.DotSettings.user
@@ -1,5 +1,6 @@
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
@@ -18,5 +19,6 @@
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.AudioMetadataReaderTest</TestId>
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldCreateAudioFilesTable</TestId>
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests.InitializeDatabase_ShouldBeIdempotent</TestId>
+ <TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests</TestId>
</TestAncestor>
</SessionState>
\ No newline at end of file