Files
QingTing-Player/lib/metadata/metadata_normalizer.dart
T
lxh2875931338 30111a5f0c feat(metadata): 元数据引擎第 2 步——置信度模型 + pending/verified 状态机
之前 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)
2026-10-23 23:45:29 +08:00

165 lines
5.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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',
);
}
}