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