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)
This commit is contained in:
@@ -57,6 +57,12 @@ class NormalizedMetadata {
|
||||
final Duration? duration;
|
||||
final Uint8List? artwork;
|
||||
|
||||
/// 字段来源:true 表示来自标签,false 表示从文件名/路径推断。
|
||||
/// 置信度模型需要区分这两者——"标签里的标题"和"文件名当标题"价值完全不同。
|
||||
final bool titleFromTag;
|
||||
final bool artistFromTag;
|
||||
final bool albumFromTag;
|
||||
|
||||
const NormalizedMetadata({
|
||||
this.title = '',
|
||||
this.artist = '',
|
||||
@@ -70,6 +76,9 @@ class NormalizedMetadata {
|
||||
this.totalDisc = 0,
|
||||
this.duration,
|
||||
this.artwork,
|
||||
this.titleFromTag = false,
|
||||
this.artistFromTag = false,
|
||||
this.albumFromTag = false,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
|
||||
@@ -4,21 +4,22 @@ import 'metadata_model.dart';
|
||||
class MetadataNormalizer {
|
||||
NormalizedMetadata normalize(RawMetadata raw,
|
||||
{String fileName = '', String filePath = ''}) {
|
||||
final title = raw.title.trim();
|
||||
final artist = raw.artist.trim();
|
||||
final tagTitle = raw.title.trim();
|
||||
final tagArtist = raw.artist.trim();
|
||||
final tagAlbum = raw.album.trim();
|
||||
|
||||
final finalTitle =
|
||||
title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
|
||||
final finalArtist =
|
||||
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
|
||||
final finalArtist2 = finalArtist.isNotEmpty
|
||||
? finalArtist
|
||||
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
|
||||
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: finalArtist2,
|
||||
album: raw.album,
|
||||
artist: finalArtist,
|
||||
album: tagAlbum,
|
||||
genres: raw.genres,
|
||||
performers: raw.performers,
|
||||
year: raw.year,
|
||||
@@ -28,6 +29,9 @@ class MetadataNormalizer {
|
||||
totalDisc: raw.totalDisc,
|
||||
duration: raw.duration,
|
||||
artwork: raw.artwork,
|
||||
titleFromTag: tagTitle.isNotEmpty,
|
||||
artistFromTag: tagArtist.isNotEmpty || raw.performers.isNotEmpty,
|
||||
albumFromTag: tagAlbum.isNotEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,16 +53,94 @@ class MetadataNormalizer {
|
||||
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) {
|
||||
// 第一版:直接接受,置信度 0.8
|
||||
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: 0.8,
|
||||
evidence: {'normalized': 0.8},
|
||||
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,
|
||||
@@ -76,7 +158,7 @@ class MetadataNormalizer {
|
||||
totalDisc: candidate.metadata.totalDisc,
|
||||
duration: candidate.metadata.duration,
|
||||
artwork: candidate.metadata.artwork,
|
||||
source: 'normalized',
|
||||
source: candidate.confidence >= threshold ? 'normalized' : 'pending',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,16 @@ class MetadataService {
|
||||
final stat = await file.stat();
|
||||
final songKey = _generateSongKey(url, stat.size);
|
||||
|
||||
// 3. SQLite 查询(高置信度直接使用)
|
||||
// 3. SQLite 查询:只有「已验证」的记录才直接返回。
|
||||
// pending 记录会继续往下走,重新读取并累计 validation_count,
|
||||
// 这就是自修复:置信度不足的记录会在下次播放时被重新确认。
|
||||
Map<String, dynamic>? dbSong;
|
||||
if (!forceRefresh) {
|
||||
final dbSong = await _db.getSong(songKey);
|
||||
dbSong = await _db.getSong(songKey);
|
||||
if (dbSong != null) {
|
||||
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||
if (confidence >= 0.6) {
|
||||
final status = dbSong['metadata_status'] as String? ?? 'pending';
|
||||
if (confidence >= 0.6 && status == 'verified') {
|
||||
debugPrint(
|
||||
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
|
||||
|
||||
@@ -104,6 +108,8 @@ class MetadataService {
|
||||
await _memoryCache.put(fileId, metadata);
|
||||
return metadata;
|
||||
}
|
||||
debugPrint('🔁 [MetadataService] 记录未验证'
|
||||
'(conf=${confidence.toStringAsFixed(2)}, status=$status),重新确认');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,47 +123,78 @@ class MetadataService {
|
||||
filePath: url,
|
||||
);
|
||||
|
||||
// 6. 评估
|
||||
// 6. 评估(含历史一致性)
|
||||
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
|
||||
final candidate = _normalizer.evaluate(normalized, history);
|
||||
final finalMetadata = _normalizer.decide(candidate);
|
||||
debugPrint(
|
||||
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
|
||||
|
||||
// 7. 保存封面图到本地
|
||||
String? artworkPath;
|
||||
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
|
||||
// 7. 与已有记录合并
|
||||
final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0;
|
||||
final validationCount = ((dbSong?['validation_count'] as int?) ?? 0) + 1;
|
||||
final useNew = dbSong == null || candidate.confidence >= prevConfidence;
|
||||
final effectiveConfidence =
|
||||
useNew ? candidate.confidence : prevConfidence;
|
||||
|
||||
// 8. 保存封面图(只有采用新结果且确实读到图时才写)
|
||||
String? artworkPath = dbSong?['artwork_path'] as String?;
|
||||
if (useNew &&
|
||||
finalMetadata.artwork != null &&
|
||||
finalMetadata.artwork!.isNotEmpty) {
|
||||
artworkPath =
|
||||
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
|
||||
}
|
||||
|
||||
// 8. 保存到 SQLite
|
||||
// 注意:remote_path 必须存「应用内的歌曲路径」(即 fileId / song.id),
|
||||
// 不能存完整 URL。playback_controller 与 AudioService 都是用相对路径
|
||||
// 调 getSongByPath() 的,存 URL 会导致永远查不到、元数据看起来“没吃上”。
|
||||
await _db.insertSong({
|
||||
final status =
|
||||
(effectiveConfidence >= 0.6 && validationCount >= 2) ? 'verified' : 'pending';
|
||||
|
||||
final row = <String, dynamic>{
|
||||
'song_key': songKey,
|
||||
// remote_path 必须存应用内路径(fileId / song.id),不能存完整 URL
|
||||
'remote_path': fileId,
|
||||
'file_size': stat.size,
|
||||
'modified_time': stat.modified.millisecondsSinceEpoch,
|
||||
'title': finalMetadata.title,
|
||||
'artist': finalMetadata.artist,
|
||||
'album': finalMetadata.album,
|
||||
'genre': finalMetadata.genre,
|
||||
'title': useNew ? finalMetadata.title : (dbSong?['title'] ?? finalMetadata.title),
|
||||
'artist':
|
||||
useNew ? finalMetadata.artist : (dbSong?['artist'] ?? finalMetadata.artist),
|
||||
'album': useNew ? finalMetadata.album : (dbSong?['album'] ?? finalMetadata.album),
|
||||
'genre': useNew ? finalMetadata.genre : (dbSong?['genre'] ?? finalMetadata.genre),
|
||||
'artwork_path': artworkPath,
|
||||
'confidence': candidate.confidence,
|
||||
'validation_count': 1,
|
||||
'first_scan': DateTime.now().millisecondsSinceEpoch,
|
||||
'confidence': effectiveConfidence,
|
||||
'validation_count': validationCount,
|
||||
'first_scan':
|
||||
dbSong?['first_scan'] ?? DateTime.now().millisecondsSinceEpoch,
|
||||
'last_scan': DateTime.now().millisecondsSinceEpoch,
|
||||
'metadata_status': candidate.confidence >= 0.6 ? 'verified' : 'pending',
|
||||
});
|
||||
'metadata_status': status,
|
||||
};
|
||||
|
||||
if (dbSong == null) {
|
||||
await _db.insertSong(row);
|
||||
} else {
|
||||
await _db.updateSong(songKey, row);
|
||||
}
|
||||
|
||||
final merged = FinalMetadata(
|
||||
title: row['title'] as String? ?? '',
|
||||
artist: row['artist'] as String? ?? '',
|
||||
album: row['album'] as String? ?? '',
|
||||
genre: row['genre'] as String? ?? '',
|
||||
genres: finalMetadata.genres,
|
||||
performers: finalMetadata.performers,
|
||||
year: finalMetadata.year,
|
||||
duration: finalMetadata.duration,
|
||||
artwork: finalMetadata.artwork,
|
||||
source: status,
|
||||
);
|
||||
|
||||
// 9. 内存缓存
|
||||
await _memoryCache.put(fileId, finalMetadata);
|
||||
await _memoryCache.put(fileId, merged);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})');
|
||||
return finalMetadata;
|
||||
debugPrint('📊 [MetadataService] $status '
|
||||
'conf=${effectiveConfidence.toStringAsFixed(2)} '
|
||||
'validations=$validationCount '
|
||||
'evidence=${candidate.evidence}');
|
||||
debugPrint('✅ [MetadataService] saved: ${merged.title} - ${merged.artist}');
|
||||
return merged;
|
||||
}
|
||||
|
||||
FinalMetadata _fallbackFromFileName(String fileName) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// test/metadata_normalizer_test.dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qingting_player/metadata/metadata_model.dart';
|
||||
import 'package:qingting_player/metadata/metadata_normalizer.dart';
|
||||
|
||||
void main() {
|
||||
final normalizer = MetadataNormalizer();
|
||||
|
||||
RawMetadata raw({
|
||||
String title = '',
|
||||
String artist = '',
|
||||
String album = '',
|
||||
int year = 0,
|
||||
}) =>
|
||||
RawMetadata(title: title, artist: artist, album: album, year: year);
|
||||
|
||||
group('normalize 字段来源', () {
|
||||
test('标签齐全时三个字段都标记为来自标签', () {
|
||||
final n = normalizer.normalize(
|
||||
raw(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦'),
|
||||
fileName: 'track01.mp3',
|
||||
filePath: '/音乐/周杰伦/track01.mp3',
|
||||
);
|
||||
expect(n.title, '夜曲');
|
||||
expect(n.artist, '周杰伦');
|
||||
expect(n.album, '十一月的萧邦');
|
||||
expect(n.titleFromTag, isTrue);
|
||||
expect(n.artistFromTag, isTrue);
|
||||
expect(n.albumFromTag, isTrue);
|
||||
});
|
||||
|
||||
test('没有标签时回退到文件名与目录', () {
|
||||
final n = normalizer.normalize(
|
||||
raw(),
|
||||
fileName: '晴天.mp3',
|
||||
filePath: '/音乐/周杰伦/晴天.mp3',
|
||||
);
|
||||
expect(n.title, '晴天');
|
||||
expect(n.artist, '周杰伦');
|
||||
expect(n.titleFromTag, isFalse);
|
||||
expect(n.artistFromTag, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('evaluate 置信度分级', () {
|
||||
NormalizedMetadata norm({
|
||||
required String title,
|
||||
required String artist,
|
||||
String album = '',
|
||||
bool titleTag = true,
|
||||
bool artistTag = true,
|
||||
bool albumTag = false,
|
||||
}) =>
|
||||
NormalizedMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
titleFromTag: titleTag,
|
||||
artistFromTag: artistTag,
|
||||
albumFromTag: albumTag,
|
||||
);
|
||||
|
||||
test('标题 + 艺术家 + 专辑 → 0.90', () {
|
||||
final c = normalizer.evaluate(
|
||||
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||
const [],
|
||||
);
|
||||
expect(c.confidence, closeTo(0.90, 0.001));
|
||||
});
|
||||
|
||||
test('只有标题和艺术家 → 0.80', () {
|
||||
final c = normalizer.evaluate(norm(title: '夜曲', artist: '周杰伦'), const []);
|
||||
expect(c.confidence, closeTo(0.80, 0.001));
|
||||
});
|
||||
|
||||
test('只有文件名(无标签)→ 0.35,低于阈值', () {
|
||||
final c = normalizer.evaluate(
|
||||
norm(title: 'track01', artist: '未知艺术家', titleTag: false, artistTag: false),
|
||||
const [],
|
||||
);
|
||||
expect(c.confidence, closeTo(0.35, 0.001));
|
||||
expect(c.confidence < 0.6, isTrue);
|
||||
});
|
||||
|
||||
test('历史一致性加权:同艺人 ≥2 条 → +0.05', () {
|
||||
final history = [
|
||||
{'artist': '周杰伦', 'album': ''},
|
||||
{'artist': '周杰伦', 'album': ''},
|
||||
];
|
||||
final c = normalizer.evaluate(
|
||||
norm(title: '夜曲', artist: '周杰伦'),
|
||||
history,
|
||||
);
|
||||
expect(c.confidence, closeTo(0.85, 0.001));
|
||||
expect(c.evidence['artist_history'], 0.05);
|
||||
});
|
||||
|
||||
test('证据可解释:每一项加分都有记录', () {
|
||||
final c = normalizer.evaluate(
|
||||
norm(title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', albumTag: true),
|
||||
const [],
|
||||
);
|
||||
expect(c.evidence.containsKey('base'), isTrue);
|
||||
expect(c.evidence.containsKey('title_from_tag'), isTrue);
|
||||
expect(c.evidence.containsKey('artist_from_tag'), isTrue);
|
||||
expect(c.evidence.containsKey('album'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('decide 阈值', () {
|
||||
test('高于阈值时 source=normalized', () {
|
||||
final c = normalizer.evaluate(
|
||||
const NormalizedMetadata(
|
||||
title: '夜曲',
|
||||
artist: '周杰伦',
|
||||
titleFromTag: true,
|
||||
artistFromTag: true,
|
||||
),
|
||||
const [],
|
||||
);
|
||||
final f = normalizer.decide(c);
|
||||
expect(f.source, 'normalized');
|
||||
expect(f.title, '夜曲');
|
||||
});
|
||||
|
||||
test('低于阈值时 source=pending', () {
|
||||
final c = normalizer.evaluate(
|
||||
const NormalizedMetadata(
|
||||
title: 'track01',
|
||||
artist: '未知艺术家',
|
||||
titleFromTag: false,
|
||||
artistFromTag: false,
|
||||
),
|
||||
const [],
|
||||
);
|
||||
final f = normalizer.decide(c);
|
||||
expect(f.source, 'pending');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user