之前 evaluate() 写死 0.8、decide() 直接透传,history 参数没人用, "自学习"只有骨架。这次补上: 置信度(MetadataNormalizer.evaluate) - 字段来源可追溯:NormalizedMetadata 新增 titleFromTag / artistFromTag / albumFromTag - 基础分 0.25;标题 +0.20、标题来自标签再 +0.10(文件名当标题则 -0.10) - 艺术家 +0.15、来自标签 +0.10;专辑 +0.10;年份 +0.03;封面 +0.02 - 历史一致性:同艺人 >=2 条 +0.05,同专辑 >=2 条 +0.03 - 结果 clamp 到 0.05~0.98,且每个加分项写入 evidence,便于日志解释 状态机(MetadataService) - 只有 confidence>=0.6 且 status=verified 的记录才直接命中 - pending 记录下次播放会重新读取并合并(自修复) - validation_count 累加;>=2 次且置信度达标才升级为 verified - 新结果置信度低于旧结果时保留旧字段,只累加验证次数 - 日志输出 status / confidence / validation_count / evidence 测试 - 新增 test/metadata_normalizer_test.dart,9 个用例覆盖来源标记、分级、 历史加权、证据、阈值;flutter test 全绿 真机验证 - 起播看门狗在真实弱网下触发:23:44:50 打开失败 → 15s 后自动重连 → 1.4s 出声(open 仅 100ms)
165 lines
5.4 KiB
Dart
165 lines
5.4 KiB
Dart
// lib/metadata/metadata_normalizer.dart
|
||
import 'metadata_model.dart';
|
||
|
||
class MetadataNormalizer {
|
||
NormalizedMetadata normalize(RawMetadata raw,
|
||
{String fileName = '', String filePath = ''}) {
|
||
final tagTitle = raw.title.trim();
|
||
final tagArtist = raw.artist.trim();
|
||
final tagAlbum = raw.album.trim();
|
||
|
||
final finalTitle =
|
||
tagTitle.isNotEmpty ? tagTitle : _inferTitleFromFileName(fileName);
|
||
|
||
final artistFromPath = _inferArtistFromPath(filePath);
|
||
final finalArtist = tagArtist.isNotEmpty
|
||
? tagArtist
|
||
: (raw.performers.isNotEmpty ? raw.performers.first : artistFromPath);
|
||
|
||
return NormalizedMetadata(
|
||
title: finalTitle,
|
||
artist: finalArtist,
|
||
album: tagAlbum,
|
||
genres: raw.genres,
|
||
performers: raw.performers,
|
||
year: raw.year,
|
||
trackNumber: raw.trackNumber,
|
||
trackTotal: raw.trackTotal,
|
||
discNumber: raw.discNumber,
|
||
totalDisc: raw.totalDisc,
|
||
duration: raw.duration,
|
||
artwork: raw.artwork,
|
||
titleFromTag: tagTitle.isNotEmpty,
|
||
artistFromTag: tagArtist.isNotEmpty || raw.performers.isNotEmpty,
|
||
albumFromTag: tagAlbum.isNotEmpty,
|
||
);
|
||
}
|
||
|
||
String _inferTitleFromFileName(String fileName) {
|
||
final dotIndex = fileName.lastIndexOf('.');
|
||
return dotIndex > 0
|
||
? fileName.substring(0, dotIndex).trim()
|
||
: fileName.trim();
|
||
}
|
||
|
||
String _inferArtistFromPath(String filePath) {
|
||
final parts = filePath.split(RegExp(r'[/\\]'));
|
||
if (parts.length >= 2) {
|
||
final candidate = parts[parts.length - 2].trim();
|
||
if (candidate.isNotEmpty && !candidate.startsWith('http')) {
|
||
return candidate;
|
||
}
|
||
}
|
||
return '未知艺术家';
|
||
}
|
||
|
||
/// 置信度评估(元数据引擎第 2 步)。
|
||
///
|
||
/// 设计原则:
|
||
/// 1. 标签 >= 推断:来自标签的字段永远比文件名/目录推断更可信;
|
||
/// 2. 字段齐全度:标题、艺术家、专辑依次加分;
|
||
/// 3. 历史一致性:同一艺人/专辑已有多条可信记录时额外加权,
|
||
/// 这是"自学习"的入口——库越大,判断越稳;
|
||
/// 4. 可解释:每个加分项都写进 evidence,方便日志与后续调参。
|
||
///
|
||
/// 分数区间大致:
|
||
/// 0.35 只有文件名
|
||
/// 0.55 标题来自标签、艺术家靠推断
|
||
/// 0.80 标题 + 艺术家都来自标签
|
||
/// 0.90 标题 + 艺术家 + 专辑
|
||
/// 0.95 以上再加年份/封面/历史一致性
|
||
CandidateMetadata evaluate(
|
||
NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
|
||
final evidence = <String, double>{};
|
||
var confidence = 0.25;
|
||
evidence['base'] = 0.25;
|
||
|
||
if (normalized.title.isNotEmpty) {
|
||
confidence += 0.20;
|
||
evidence['title'] = 0.20;
|
||
}
|
||
if (normalized.titleFromTag) {
|
||
confidence += 0.10;
|
||
evidence['title_from_tag'] = 0.10;
|
||
} else if (normalized.title.isNotEmpty) {
|
||
// 拿文件名当标题:明确扣分
|
||
confidence -= 0.10;
|
||
evidence['title_inferred_penalty'] = -0.10;
|
||
}
|
||
|
||
if (normalized.artist.isNotEmpty && normalized.artist != '未知艺术家') {
|
||
confidence += 0.15;
|
||
evidence['artist'] = 0.15;
|
||
}
|
||
if (normalized.artistFromTag) {
|
||
confidence += 0.10;
|
||
evidence['artist_from_tag'] = 0.10;
|
||
}
|
||
|
||
if (normalized.album.isNotEmpty) {
|
||
confidence += 0.10;
|
||
evidence['album'] = 0.10;
|
||
}
|
||
if (normalized.year > 0) {
|
||
confidence += 0.03;
|
||
evidence['year'] = 0.03;
|
||
}
|
||
if (normalized.artwork != null && normalized.artwork!.isNotEmpty) {
|
||
confidence += 0.02;
|
||
evidence['artwork'] = 0.02;
|
||
}
|
||
|
||
// ---- 历史一致性(自学习的核心)----
|
||
if (history.isNotEmpty) {
|
||
final sameArtist = history
|
||
.where((row) => (row['artist'] as String?) == normalized.artist)
|
||
.length;
|
||
if (sameArtist >= 2) {
|
||
confidence += 0.05;
|
||
evidence['artist_history'] = 0.05;
|
||
}
|
||
|
||
if (normalized.album.isNotEmpty) {
|
||
final sameAlbum = history
|
||
.where((row) => (row['album'] as String?) == normalized.album)
|
||
.length;
|
||
if (sameAlbum >= 2) {
|
||
confidence += 0.03;
|
||
evidence['album_history'] = 0.03;
|
||
}
|
||
}
|
||
}
|
||
|
||
confidence = confidence.clamp(0.05, 0.98);
|
||
return CandidateMetadata(
|
||
metadata: normalized,
|
||
confidence: double.parse(confidence.toStringAsFixed(2)),
|
||
evidence: evidence,
|
||
);
|
||
}
|
||
|
||
/// 按阈值决定最终元数据。
|
||
/// 低于阈值时仍返回数据(用于展示),但 source 标成 pending,
|
||
/// 调用方据此累计 validation_count、下次播放重新确认。
|
||
FinalMetadata decide(CandidateMetadata candidate, {double threshold = 0.6}) {
|
||
return FinalMetadata(
|
||
title: candidate.metadata.title,
|
||
artist: candidate.metadata.artist,
|
||
album: candidate.metadata.album,
|
||
genre: candidate.metadata.genres.isNotEmpty
|
||
? candidate.metadata.genres.first
|
||
: '',
|
||
genres: candidate.metadata.genres,
|
||
performers: candidate.metadata.performers,
|
||
year: candidate.metadata.year,
|
||
trackNumber: candidate.metadata.trackNumber,
|
||
trackTotal: candidate.metadata.trackTotal,
|
||
discNumber: candidate.metadata.discNumber,
|
||
totalDisc: candidate.metadata.totalDisc,
|
||
duration: candidate.metadata.duration,
|
||
artwork: candidate.metadata.artwork,
|
||
source: candidate.confidence >= threshold ? 'normalized' : 'pending',
|
||
);
|
||
}
|
||
}
|