WIP: 扫描扫描指定目录内的所有音频文件,获取其meta信息,并将其添加至一个名为default的数据库。

This commit is contained in:
2026-04-16 19:13:32 +08:00
parent ecf3751e2d
commit 20ae0909cf
5 changed files with 716 additions and 2 deletions
+127
View File
@@ -0,0 +1,127 @@
namespace OCES.Resonance.Core;
/// <summary>
/// 音频元数据验证器,验证元数据完整性和格式
/// </summary>
public class AudioFileMetaValidator
{
/// <summary>
/// 验证必填字段是否完整
/// </summary>
/// <param name="meta">音频元数据对象</param>
/// <param name="errors">验证错误列表</param>
/// <returns>是否验证通过</returns>
public bool Validate(AudioFileMeta meta, out List<string> errors)
{
errors = new List<string>();
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;
}
/// <summary>
/// 验证必填字符串字段
/// </summary>
private static void ValidateRequiredField(string? value, string fieldName, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add($"{fieldName} 不能为空");
}
}
/// <summary>
/// 验证必填值类型字段
/// </summary>
private static void ValidateRequiredField<T>(T value, string fieldName, List<string> errors) where T : struct
{
if (EqualityComparer<T>.Default.Equals(value, default))
{
errors.Add($"{fieldName} 不能为空");
}
}
/// <summary>
/// 验证正数值
/// </summary>
private static void ValidatePositiveValue(double value, string fieldName, List<string> errors)
{
if (value <= 0)
{
errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
}
}
/// <summary>
/// 验证正数整数值
/// </summary>
private static void ValidatePositiveValue(int value, string fieldName, List<string> errors)
{
if (value <= 0)
{
errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
}
}
/// <summary>
/// 验证正数无符号整数值
/// </summary>
private static void ValidatePositiveValue(uint value, string fieldName, List<string> errors)
{
if (value <= 0)
{
errors.Add($"{fieldName} 必须为正数 (当前值:{value})");
}
}
/// <summary>
/// 验证 MD5 格式是否有效
/// </summary>
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'));
}
}
+49
View File
@@ -0,0 +1,49 @@
namespace OCES.Resonance.Core;
/// <summary>
/// 音频文件扫描器,负责递归扫描目录并识别音频文件
/// </summary>
public class AudioFileScanner
{
private static readonly string[] SupportedExtensions =
{
".wav", ".mp3", ".flac", ".aiff", ".aif", ".m4a", ".ogg", ".wma", ".bwf", ".wav64"
};
/// <summary>
/// 扫描指定目录,返回所有音频文件路径
/// </summary>
/// <param name="directoryPath">要扫描的目录路径</param>
/// <param name="recursive">是否递归扫描子目录</param>
/// <returns>音频文件路径集合</returns>
public IEnumerable<string> 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);
}
/// <summary>
/// 判断文件是否为支持的音频格式
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>是否为支持的音频格式</returns>
public bool IsSupportedAudioFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return false;
}
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return SupportedExtensions.Contains(extension);
}
}
+135
View File
@@ -0,0 +1,135 @@
namespace OCES.Resonance.Core;
/// <summary>
/// 音频库管理服务,协调扫描、读取和入库的完整流程
/// </summary>
public class AudioLibraryService
{
private readonly AudioFileScanner _scanner;
private readonly AudioMetadataReader _metadataReader;
public AudioLibraryService()
{
_scanner = new AudioFileScanner();
_metadataReader = new AudioMetadataReader();
}
/// <summary>
/// 扫描目录并将结果添加到数据库
/// </summary>
/// <param name="directoryPath">要扫描的目录路径</param>
/// <param name="databaseName">数据库名称(不含扩展名)</param>
/// <param name="skipExisting">是否跳过已存在的文件</param>
/// <returns>扫描结果统计</returns>
public async Task<ScanResult> ScanAndImportToLibrary(
string directoryPath,
string databaseName = "default",
bool skipExisting = true)
{
var result = new ScanResult
{
TotalFiles = 0,
SuccessCount = 0,
SkipCount = 0,
ErrorCount = 0,
Errors = new List<string>()
};
// 初始化数据库
Database.InitializeDatabase();
// 扫描所有音频文件
var audioFiles = _scanner.ScanDirectory(directoryPath).ToList();
result.TotalFiles = audioFiles.Count;
if (audioFiles.Count == 0)
{
return result;
}
var metadataList = new List<AudioFileMeta>();
// 读取每个文件的元数据
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);
}
/// <summary>
/// 计算文件 MD5(用于查重,不创建完整 AudioFileMeta 对象)
/// </summary>
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();
}
}
/// <summary>
/// 扫描结果统计
/// </summary>
public class ScanResult
{
/// <summary>扫描到的文件总数</summary>
public int TotalFiles { get; set; }
/// <summary>成功入库的文件数</summary>
public int SuccessCount { get; set; }
/// <summary>跳过的文件数(已存在)</summary>
public int SkipCount { get; set; }
/// <summary>读取失败的文件数</summary>
public int ErrorCount { get; set; }
/// <summary>错误详情列表</summary>
public List<string> Errors { get; set; } = new();
/// <summary>
/// 获取人类可读的统计报告
/// </summary>
public string GetSummary()
{
return $"扫描完成:共 {TotalFiles} 个文件,成功 {SuccessCount} 个,跳过 {SkipCount} 个,失败 {ErrorCount} 个";
}
}
+173
View File
@@ -0,0 +1,173 @@
using System.Security.Cryptography;
using ATL;
namespace OCES.Resonance.Core;
/// <summary>
/// 音频元数据读取器,使用 ATL 库读取音频文件的技术参数和元数据
/// </summary>
public class AudioMetadataReader
{
/// <summary>
/// 从音频文件读取元数据
/// </summary>
/// <param name="filePath">音频文件路径</param>
/// <returns>音频元数据对象</returns>
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;
}
/// <summary>
/// 获取 BWF 字段值
/// </summary>
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;
}
/// <summary>
/// 获取自定义字段值
/// </summary>
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;
}
/// <summary>
/// 获取音频位深度
/// </summary>
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
};
}
/// <summary>
/// 计算文件 MD5 值
/// </summary>
/// <param name="filePath">文件路径</param>
/// <returns>MD5 哈希值(十六进制字符串)</returns>
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();
}
/// <summary>
/// 生成 UUID
/// </summary>
/// <returns>UUID 字符串</returns>
private static string GenerateUuid()
{
return Guid.NewGuid().ToString("N"); // 无连字符的 UUID
}
}
+232 -2
View File
@@ -1,9 +1,239 @@
using System.Data;
using System.Data.SQLite;
using Dapper;
namespace OCES.Resonance.Core; namespace OCES.Resonance.Core;
public static class Database public static class Database
{ {
internal static bool AddEntry() static readonly string DefaultConnectionString = "Data Source=default.db;Version=3;";
/// <summary>
/// 获取数据库连接
/// </summary>
/// <param name="dbName">数据库名称(不含扩展名)</param>
/// <returns>数据库连接</returns>
public static IDbConnection GetConnection(string dbName = "default")
{ {
return false; var connectionString = $"Data Source={dbName}.db;Version=3;";
return new SQLiteConnection(connectionString);
}
/// <summary>
/// 初始化数据库表结构
/// </summary>
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);
}
/// <summary>
/// 添加单条音频记录
/// </summary>
/// <param name="meta">音频元数据</param>
/// <returns>是否添加成功</returns>
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;
}
}
/// <summary>
/// 批量添加音频记录
/// </summary>
/// <param name="entries">音频元数据列表</param>
/// <returns>成功添加的记录数</returns>
public static int AddEntries(IEnumerable<AudioFileMeta> 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;
}
/// <summary>
/// 检查记录是否已存在
/// </summary>
/// <param name="md5">文件 MD5 值</param>
/// <param name="filePath">文件路径</param>
/// <returns>是否存在</returns>
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<int>(sql, new { Md5 = md5, Path = filePath });
return count > 0;
} }
} }