diff --git a/src/Core/AudioFileMetaValidator.cs b/src/Core/AudioFileMetaValidator.cs new file mode 100644 index 0000000..45f5c44 --- /dev/null +++ b/src/Core/AudioFileMetaValidator.cs @@ -0,0 +1,127 @@ +namespace OCES.Resonance.Core; + +/// +/// 音频元数据验证器,验证元数据完整性和格式 +/// +public class AudioFileMetaValidator +{ + /// + /// 验证必填字段是否完整 + /// + /// 音频元数据对象 + /// 验证错误列表 + /// 是否验证通过 + public bool Validate(AudioFileMeta meta, out List errors) + { + errors = new List(); + + if (meta == null) + { + errors.Add("元数据对象为空"); + return false; + } + + // 验证必备字符串字段 + ValidateRequiredField(meta.UniqueId, "UniqueId", errors); + ValidateRequiredField(meta.Md5, "Md5", errors); + ValidateRequiredField(meta.Path, "Path", errors); + ValidateRequiredField(meta.Filename, "Filename", errors); + ValidateRequiredField(meta.Folder, "Folder", errors); + ValidateRequiredField(meta.Directory, "Directory", errors); + ValidateRequiredField(meta.Type, "Type", errors); + + // 验证数值字段 + ValidatePositiveValue(meta.Duration, "Duration", errors); + ValidatePositiveValue(meta.TotalSamples, "TotalSamples", errors); + ValidatePositiveValue(meta.BitDepth, "BitDepth", errors); + ValidatePositiveValue(meta.Channels, "Channels", errors); + ValidatePositiveValue(meta.SampleRate, "SampleRate", errors); + + // 验证日期字段 + ValidateRequiredField(meta.DateAdded, "DateAdded", errors); + ValidateRequiredField(meta.OriginalModificationDate, "OriginalModificationDate", errors); + ValidateRequiredField(meta.OriginationTime, "OriginationTime", errors); + + // 验证文件路径是否存在 + if (!File.Exists(meta.Path)) + { + errors.Add($"文件不存在:{meta.Path}"); + } + + // 验证 MD5 格式(32 位十六进制) + if (!IsValidMd5(meta.Md5)) + { + errors.Add($"MD5 格式无效:{meta.Md5}"); + } + + return errors.Count == 0; + } + + /// + /// 验证必填字符串字段 + /// + private static void ValidateRequiredField(string? value, string fieldName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add($"{fieldName} 不能为空"); + } + } + + /// + /// 验证必填值类型字段 + /// + private static void ValidateRequiredField(T value, string fieldName, List errors) where T : struct + { + if (EqualityComparer.Default.Equals(value, default)) + { + errors.Add($"{fieldName} 不能为空"); + } + } + + /// + /// 验证正数值 + /// + private static void ValidatePositiveValue(double value, string fieldName, List errors) + { + if (value <= 0) + { + errors.Add($"{fieldName} 必须为正数 (当前值:{value})"); + } + } + + /// + /// 验证正数整数值 + /// + private static void ValidatePositiveValue(int value, string fieldName, List errors) + { + if (value <= 0) + { + errors.Add($"{fieldName} 必须为正数 (当前值:{value})"); + } + } + + /// + /// 验证正数无符号整数值 + /// + private static void ValidatePositiveValue(uint value, string fieldName, List errors) + { + if (value <= 0) + { + errors.Add($"{fieldName} 必须为正数 (当前值:{value})"); + } + } + + /// + /// 验证 MD5 格式是否有效 + /// + private static bool IsValidMd5(string md5) + { + if (string.IsNullOrWhiteSpace(md5) || md5.Length != 32) + { + return false; + } + + return md5.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')); + } +} diff --git a/src/Core/AudioFileScanner.cs b/src/Core/AudioFileScanner.cs new file mode 100644 index 0000000..4982f31 --- /dev/null +++ b/src/Core/AudioFileScanner.cs @@ -0,0 +1,49 @@ +namespace OCES.Resonance.Core; + +/// +/// 音频文件扫描器,负责递归扫描目录并识别音频文件 +/// +public class AudioFileScanner +{ + private static readonly string[] SupportedExtensions = + { + ".wav", ".mp3", ".flac", ".aiff", ".aif", ".m4a", ".ogg", ".wma", ".bwf", ".wav64" + }; + + /// + /// 扫描指定目录,返回所有音频文件路径 + /// + /// 要扫描的目录路径 + /// 是否递归扫描子目录 + /// 音频文件路径集合 + public IEnumerable ScanDirectory(string directoryPath, bool recursive = true) + { + if (!Directory.Exists(directoryPath)) + { + throw new DirectoryNotFoundException($"目录不存在:{directoryPath}"); + } + + var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + return SupportedExtensions + .SelectMany(ext => Directory.EnumerateFiles(directoryPath, $"*{ext}", searchOption)) + .Where(IsSupportedAudioFile) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + /// + /// 判断文件是否为支持的音频格式 + /// + /// 文件路径 + /// 是否为支持的音频格式 + public bool IsSupportedAudioFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return false; + } + + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return SupportedExtensions.Contains(extension); + } +} diff --git a/src/Core/AudioLibraryService.cs b/src/Core/AudioLibraryService.cs new file mode 100644 index 0000000..f9144a0 --- /dev/null +++ b/src/Core/AudioLibraryService.cs @@ -0,0 +1,135 @@ +namespace OCES.Resonance.Core; + +/// +/// 音频库管理服务,协调扫描、读取和入库的完整流程 +/// +public class AudioLibraryService +{ + private readonly AudioFileScanner _scanner; + private readonly AudioMetadataReader _metadataReader; + + public AudioLibraryService() + { + _scanner = new AudioFileScanner(); + _metadataReader = new AudioMetadataReader(); + } + + /// + /// 扫描目录并将结果添加到数据库 + /// + /// 要扫描的目录路径 + /// 数据库名称(不含扩展名) + /// 是否跳过已存在的文件 + /// 扫描结果统计 + public async Task ScanAndImportToLibrary( + string directoryPath, + string databaseName = "default", + bool skipExisting = true) + { + var result = new ScanResult + { + TotalFiles = 0, + SuccessCount = 0, + SkipCount = 0, + ErrorCount = 0, + Errors = new List() + }; + + // 初始化数据库 + Database.InitializeDatabase(); + + // 扫描所有音频文件 + var audioFiles = _scanner.ScanDirectory(directoryPath).ToList(); + result.TotalFiles = audioFiles.Count; + + if (audioFiles.Count == 0) + { + return result; + } + + var metadataList = new List(); + + // 读取每个文件的元数据 + foreach (var filePath in audioFiles) + { + try + { + // 计算 MD5 用于查重 + var md5 = CalculateMd5ForFile(filePath); + + // 检查是否已存在 + if (skipExisting && Database.EntryExists(md5, filePath)) + { + result.SkipCount++; + continue; + } + + // 读取元数据 + var metadata = _metadataReader.ReadMetadata(filePath); + metadataList.Add(metadata); + result.SuccessCount++; + } + catch (Exception ex) + { + result.ErrorCount++; + result.Errors.Add($"读取失败 {filePath}: {ex.Message}"); + } + } + + // 批量入库 + if (metadataList.Count > 0) + { + try + { + Database.AddEntries(metadataList); + } + catch (Exception ex) + { + result.Errors.Add($"批量入库失败:{ex.Message}"); + throw; + } + } + + return await Task.FromResult(result); + } + + /// + /// 计算文件 MD5(用于查重,不创建完整 AudioFileMeta 对象) + /// + private static string CalculateMd5ForFile(string filePath) + { + using var md5 = System.Security.Cryptography.MD5.Create(); + using var stream = File.OpenRead(filePath); + var hash = md5.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } +} + +/// +/// 扫描结果统计 +/// +public class ScanResult +{ + /// 扫描到的文件总数 + public int TotalFiles { get; set; } + + /// 成功入库的文件数 + public int SuccessCount { get; set; } + + /// 跳过的文件数(已存在) + public int SkipCount { get; set; } + + /// 读取失败的文件数 + public int ErrorCount { get; set; } + + /// 错误详情列表 + public List Errors { get; set; } = new(); + + /// + /// 获取人类可读的统计报告 + /// + public string GetSummary() + { + return $"扫描完成:共 {TotalFiles} 个文件,成功 {SuccessCount} 个,跳过 {SkipCount} 个,失败 {ErrorCount} 个"; + } +} diff --git a/src/Core/AudioMetadataReader.cs b/src/Core/AudioMetadataReader.cs new file mode 100644 index 0000000..fcdb4c8 --- /dev/null +++ b/src/Core/AudioMetadataReader.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using ATL; + +namespace OCES.Resonance.Core; + +/// +/// 音频元数据读取器,使用 ATL 库读取音频文件的技术参数和元数据 +/// +public class AudioMetadataReader +{ + /// + /// 从音频文件读取元数据 + /// + /// 音频文件路径 + /// 音频元数据对象 + public AudioFileMeta ReadMetadata(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"文件不存在:{filePath}"); + } + + var track = new Track(filePath); + var fileInfo = new FileInfo(filePath); + + // 获取音频技术参数 + var durationSeconds = track.DurationMs / 1000.0; + var sampleRate = track.SampleRate; + var channels = track.ChannelsArrangement?.NbChannels ?? 2; + var bitDepth = GetBitDepth(track); + var totalSamples = (uint)(durationSeconds * sampleRate); + + // 提取路径信息 + var fileName = Path.GetFileName(filePath); + var folder = Path.GetDirectoryName(filePath) ?? string.Empty; + var directory = Path.GetDirectoryName(filePath) ?? string.Empty; + var folderName = new DirectoryInfo(folder).Name; + + // 生成唯一标识 + var md5 = CalculateMd5(filePath); + var uniqueId = GenerateUuid(); + + // 获取文件修改时间 + var lastWriteTime = fileInfo.LastWriteTime; + var createTime = fileInfo.CreationTime; + + // 获取文件扩展名作为类型 + var fileType = Path.GetExtension(filePath).TrimStart('.').ToUpperInvariant(); + + // 映射 ATL 元数据到 AudioFileMeta + var meta = new AudioFileMeta + { + // 必备条目 + Id = 0, // 自增 ID,由数据库生成 + UniqueId = uniqueId, + Md5 = md5, + Path = Path.GetFullPath(filePath), + Filename = fileName, + Folder = folderName, + Directory = directory, + Duration = durationSeconds, + TotalSamples = totalSamples, + BitDepth = bitDepth, + Channels = channels, + SampleRate = (int)sampleRate, + Type = fileType, + DateAdded = DateTime.Now, + OriginalModificationDate = lastWriteTime, + OriginationTime = createTime, + + // 可选条目 + Bpm = track.BPM > 0 ? track.BPM : null, + Description = track.Description, + Genre = track.Genre, + Artist = track.Artist, + Composer = track.Composer, + Publisher = track.Publisher, + Copyright = track.Copyright, + + // BWF 特定字段 (通过附加数据获取) + CodingHistory = GetBwfField(track, "CodingHistory"), + Originator = GetBwfField(track, "Originator"), + OriginatorRef = GetBwfField(track, "OriginatorRef"), + }; + + return meta; + } + + /// + /// 获取 BWF 字段值 + /// + private static string? GetBwfField(Track track, string fieldName) + { + try + { + if (track.AdditionalFields != null && track.AdditionalFields.TryGetValue(fieldName, out string? field)) + { + return field; + } + } + catch + { + // 忽略 BWF 字段读取错误 + } + return null; + } + + /// + /// 获取自定义字段值 + /// + private static string? GetCustomField(Track track, int index) + { + try + { + string fieldName = $"User{index}"; + if (track.AdditionalFields != null && track.AdditionalFields.TryGetValue(fieldName, out string? field)) + { + return field; + } + } + catch + { + // 忽略自定义字段读取错误 + } + return null; + } + + /// + /// 获取音频位深度 + /// + private static int GetBitDepth(Track track) + { + // ATL 可能通过不同方式提供位深信息 + if (track.BitDepth > 0) + { + return track.BitDepth; + } + + // 根据格式推断默认位深 + var extension = Path.GetExtension(track.Path).ToLowerInvariant(); + return extension switch + { + ".wav" or ".bwf" or ".wav64" => 24, // 现代 WAV 通常 24bit + ".flac" => 24, + ".aiff" or ".aif" => 16, + ".mp3" => 16, // MP3 实际没有位深概念,默认 16 + _ => 16 + }; + } + + /// + /// 计算文件 MD5 值 + /// + /// 文件路径 + /// MD5 哈希值(十六进制字符串) + private static string CalculateMd5(string filePath) + { + using var md5 = MD5.Create(); + using var stream = File.OpenRead(filePath); + + var hash = md5.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + /// + /// 生成 UUID + /// + /// UUID 字符串 + private static string GenerateUuid() + { + return Guid.NewGuid().ToString("N"); // 无连字符的 UUID + } +} diff --git a/src/Core/Database.cs b/src/Core/Database.cs index e53d385..461620b 100644 --- a/src/Core/Database.cs +++ b/src/Core/Database.cs @@ -1,9 +1,239 @@ +using System.Data; +using System.Data.SQLite; +using Dapper; + namespace OCES.Resonance.Core; public static class Database { - internal static bool AddEntry() + static readonly string DefaultConnectionString = "Data Source=default.db;Version=3;"; + + /// + /// 获取数据库连接 + /// + /// 数据库名称(不含扩展名) + /// 数据库连接 + public static IDbConnection GetConnection(string dbName = "default") { - return false; + var connectionString = $"Data Source={dbName}.db;Version=3;"; + return new SQLiteConnection(connectionString); + } + + /// + /// 初始化数据库表结构 + /// + public static void InitializeDatabase() + { + using var connection = GetConnection(); + connection.Open(); + + const string sql = @" + CREATE TABLE IF NOT EXISTS audio_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unique_id TEXT NOT NULL UNIQUE, + short_id TEXT, + md5 TEXT NOT NULL, + path TEXT NOT NULL, + filename TEXT NOT NULL, + folder TEXT NOT NULL, + directory TEXT NOT NULL, + duration REAL NOT NULL, + total_samples INTEGER NOT NULL, + bit_depth INTEGER NOT NULL, + channels INTEGER NOT NULL, + sample_rate INTEGER NOT NULL, + type TEXT NOT NULL, + date_added TEXT NOT NULL, + original_modification_date TEXT NOT NULL, + origination_time TEXT NOT NULL, + + bpm REAL, + frame_rate TEXT, + timecode INTEGER, + description TEXT, + category TEXT, + subcategory TEXT, + cat_id TEXT, + category_full TEXT, + genre TEXT, + style TEXT, + mood TEXT, + keywords TEXT, + rating INTEGER, + artist TEXT, + composer TEXT, + designer TEXT, + recordist TEXT, + publisher TEXT, + manufacturer TEXT, + originator TEXT, + originator_ref TEXT, + project_name TEXT, + library TEXT, + cd_title TEXT, + track_title TEXT, + episode TEXT, + scene TEXT, + take TEXT, + tape TEXT, + cue_number INTEGER, + sync_point INTEGER, + release_date TEXT, + track_year TEXT, + is_edited INTEGER, + is_split INTEGER, + location TEXT, + group TEXT, + markers TEXT, + comments TEXT, + notes TEXT, + copyright TEXT, + coding_history TEXT, + microphone TEXT, + mic_perspective TEXT, + user1 TEXT, + user2 TEXT, + user3 TEXT, + user4 TEXT, + user5 TEXT, + user6 TEXT, + user7 TEXT, + user8 TEXT, + + INDEX idx_md5 (md5), + INDEX idx_path (path), + INDEX idx_unique_id (unique_id) + ); + "; + + connection.Execute(sql); + } + + /// + /// 添加单条音频记录 + /// + /// 音频元数据 + /// 是否添加成功 + public static bool AddEntry(AudioFileMeta meta) + { + try + { + using var connection = GetConnection(); + connection.Open(); + + const string sql = @" + INSERT INTO audio_files ( + unique_id, short_id, md5, path, filename, folder, directory, + duration, total_samples, bit_depth, channels, sample_rate, type, + date_added, original_modification_date, origination_time, + bpm, frame_rate, timecode, description, category, subcategory, + cat_id, category_full, genre, style, mood, keywords, rating, + artist, composer, designer, recordist, publisher, manufacturer, + originator, 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, microphone, + mic_perspective, user1, user2, user3, user4, user5, user6, user7, user8 + ) VALUES ( + @UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory, + @Duration, @TotalSamples, @BitDepth, @Channels, @SampleRate, @Type, + @DateAdded, @OriginalModificationDate, @OriginationTime, + @Bpm, @FrameRate, @Timecode, @Description, @Category, @Subcategory, + @CatId, @CategoryFull, @Genre, @Style, @Mood, @Keywords, @Rating, + @Artist, @Composer, @Designer, @Recordist, @Publisher, @Manufacturer, + @Originator, @OriginatorRef, @ProjectName, @Library, @CdTitle, + @TrackTitle, @Episode, @Scene, @Take, @Tape, @CueNumber, @SyncPoint, + @ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group, + @Markers, @Comments, @Notes, @Copyright, @CodingHistory, @Microphone, + @MicPerspective, @User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8 + ); + "; + + connection.Execute(sql, meta); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// 批量添加音频记录 + /// + /// 音频元数据列表 + /// 成功添加的记录数 + public static int AddEntries(IEnumerable entries) + { + var count = 0; + using var connection = GetConnection(); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + try + { + const string sql = @" + INSERT INTO audio_files ( + unique_id, short_id, md5, path, filename, folder, directory, + duration, total_samples, bit_depth, channels, sample_rate, type, + date_added, original_modification_date, origination_time, + bpm, frame_rate, timecode, description, category, subcategory, + cat_id, category_full, genre, style, mood, keywords, rating, + artist, composer, designer, recordist, publisher, manufacturer, + originator, 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, microphone, + mic_perspective, user1, user2, user3, user4, user5, user6, user7, user8 + ) VALUES ( + @UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory, + @Duration, @TotalSamples, @BitDepth, @Channels, @SampleRate, @Type, + @DateAdded, @OriginalModificationDate, @OriginationTime, + @Bpm, @FrameRate, @Timecode, @Description, @Category, @Subcategory, + @CatId, @CategoryFull, @Genre, @Style, @Mood, @Keywords, @Rating, + @Artist, @Composer, @Designer, @Recordist, @Publisher, @Manufacturer, + @Originator, @OriginatorRef, @ProjectName, @Library, @CdTitle, + @TrackTitle, @Episode, @Scene, @Take, @Tape, @CueNumber, @SyncPoint, + @ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group, + @Markers, @Comments, @Notes, @Copyright, @CodingHistory, @Microphone, + @MicPerspective, @User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8 + ); + "; + + foreach (var entry in entries) + { + connection.Execute(sql, entry, transaction); + count++; + } + + transaction.Commit(); + } + catch (Exception) + { + transaction.Rollback(); + throw; + } + + return count; + } + + /// + /// 检查记录是否已存在 + /// + /// 文件 MD5 值 + /// 文件路径 + /// 是否存在 + public static bool EntryExists(string md5, string filePath) + { + using var connection = GetConnection(); + connection.Open(); + + const string sql = @" + SELECT COUNT(*) FROM audio_files + WHERE md5 = @Md5 OR path = @Path; + "; + + var count = connection.ExecuteScalar(sql, new { Md5 = md5, Path = filePath }); + return count > 0; } }