diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index 072782f..b0c4158 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -6,6 +6,7 @@ import '../database/song_database.dart'; import 'metadata_model.dart'; import 'metadata_reader.dart'; import 'metadata_normalizer.dart'; +import 'metadata_stability.dart'; import 'metadata_cache.dart'; import 'file_provider.dart'; import '../utils/artwork_helper.dart'; @@ -128,9 +129,15 @@ class MetadataService { final candidate = _normalizer.evaluate(normalized, history); final finalMetadata = _normalizer.decide(candidate); - // 7. 与已有记录合并 + // 7. 与已有记录合并(一致性 + 次数由状态机决定) final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0; - final validationCount = ((dbSong?['validation_count'] as int?) ?? 0) + 1; + final decision = MetadataStability.decide( + previous: dbSong, + candidate: candidate, + title: finalMetadata.title, + artist: finalMetadata.artist, + ); + final validationCount = decision.validationCount; final useNew = dbSong == null || candidate.confidence >= prevConfidence; final effectiveConfidence = useNew ? candidate.confidence : prevConfidence; @@ -144,8 +151,9 @@ class MetadataService { await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey); } - final status = - (effectiveConfidence >= 0.6 && validationCount >= 2) ? 'verified' : 'pending'; + final status = decision.status; + debugPrint('🧭 [MetadataService] 状态机: ${decision.reason} → $status' + '(validations=$validationCount)'); final row = { 'song_key': songKey, diff --git a/lib/metadata/metadata_stability.dart b/lib/metadata/metadata_stability.dart new file mode 100644 index 0000000..eefad75 --- /dev/null +++ b/lib/metadata/metadata_stability.dart @@ -0,0 +1,98 @@ +// lib/metadata/metadata_stability.dart +import 'metadata_model.dart'; + +/// 状态机判定结果 +class StabilityDecision { + /// 'verified' | 'pending' + final String status; + final String reason; + final int validationCount; + + const StabilityDecision({ + required this.status, + required this.reason, + required this.validationCount, + }); + + bool get isVerified => status == 'verified'; +} + +/// 元数据状态机:决定一条记录能不能升级为 verified。 +/// +/// 第 2 步的规则是"置信度 >= 0.6 且验证次数 >= 2",太粗暴: +/// 两次读到的是不同结果也会被当成"验证通过"。这里加入一致性检查—— +/// +/// - 首次见到 → pending(需要第二次确认) +/// - 与上次记录冲突(标题或艺术家明显不同)→ 次数重置为 1,继续 pending +/// - 一致且次数 >= 2、置信度达标 → verified +/// - 置信度不足 → pending +/// +/// 这样一次误读不会把错误标签固化,下次播放会重新校正。 +class MetadataStability { + static const double threshold = 0.6; + + static StabilityDecision decide({ + required Map? previous, + required CandidateMetadata candidate, + required String title, + required String artist, + }) { + if (previous == null) { + return const StabilityDecision( + status: 'pending', + reason: 'first_seen', + validationCount: 1, + ); + } + + final prevConfidence = + (previous['confidence'] as num?)?.toDouble() ?? 0.0; + final prevTitle = previous['title'] as String? ?? ''; + final prevArtist = previous['artist'] as String? ?? ''; + final prevCount = (previous['validation_count'] as int?) ?? 0; + + final consistent = _similar(prevTitle, title) && _similar(prevArtist, artist); + if (!consistent) { + return const StabilityDecision( + status: 'pending', + reason: 'conflict', + validationCount: 1, + ); + } + + final count = prevCount + 1; + final best = candidate.confidence > prevConfidence + ? candidate.confidence + : prevConfidence; + + if (best < threshold) { + return StabilityDecision( + status: 'pending', + reason: 'low_confidence', + validationCount: count, + ); + } + + if (count < 2) { + return StabilityDecision( + status: 'pending', + reason: 'need_second_validation', + validationCount: count, + ); + } + + return StabilityDecision( + status: 'verified', + reason: 'stable', + validationCount: count, + ); + } + + /// 字段缺失不算冲突;只有两边都有值且不同才算。 + static bool _similar(String a, String b) { + final left = a.trim().toLowerCase(); + final right = b.trim().toLowerCase(); + if (left.isEmpty || right.isEmpty) return true; + return left == right; + } +} diff --git a/test/metadata_stability_test.dart b/test/metadata_stability_test.dart new file mode 100644 index 0000000..8db659e --- /dev/null +++ b/test/metadata_stability_test.dart @@ -0,0 +1,124 @@ +// test/metadata_stability_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:qingting_player/metadata/metadata_model.dart'; +import 'package:qingting_player/metadata/metadata_stability.dart'; + +void main() { + CandidateMetadata candidate({double confidence = 0.8}) => CandidateMetadata( + metadata: const NormalizedMetadata(title: '夜曲', artist: '周杰伦'), + confidence: confidence, + ); + + group('MetadataStability', () { + test('首次见到 → pending,次数 1', () { + final d = MetadataStability.decide( + previous: null, + candidate: candidate(), + title: '夜曲', + artist: '周杰伦', + ); + expect(d.status, 'pending'); + expect(d.reason, 'first_seen'); + expect(d.validationCount, 1); + }); + + test('第二次结果一致且置信度达标 → verified', () { + final d = MetadataStability.decide( + previous: { + 'title': '夜曲', + 'artist': '周杰伦', + 'confidence': 0.8, + 'validation_count': 1, + }, + candidate: candidate(), + title: '夜曲', + artist: '周杰伦', + ); + expect(d.status, 'verified'); + expect(d.reason, 'stable'); + expect(d.validationCount, 2); + }); + + test('标题或艺术家冲突 → 次数重置为 1,保持 pending', () { + final d = MetadataStability.decide( + previous: { + 'title': '夜曲', + 'artist': '周杰伦', + 'confidence': 0.9, + 'validation_count': 5, + }, + candidate: candidate(), + title: '完全不同的歌', + artist: '别的歌手', + ); + expect(d.status, 'pending'); + expect(d.reason, 'conflict'); + expect(d.validationCount, 1); + }); + + test('置信度不足 → pending,但次数继续累计', () { + final d = MetadataStability.decide( + previous: { + 'title': '夜曲', + 'artist': '周杰伦', + 'confidence': 0.4, + 'validation_count': 3, + }, + candidate: candidate(confidence: 0.5), + title: '夜曲', + artist: '周杰伦', + ); + expect(d.status, 'pending'); + expect(d.reason, 'low_confidence'); + expect(d.validationCount, 4); + }); + + test('一致但只验证过一次 → pending', () { + final d = MetadataStability.decide( + previous: { + 'title': '夜曲', + 'artist': '周杰伦', + 'confidence': 0.8, + 'validation_count': 0, + }, + candidate: candidate(), + title: '夜曲', + artist: '周杰伦', + ); + expect(d.status, 'pending'); + expect(d.reason, 'need_second_validation'); + expect(d.validationCount, 1); + }); + + test('字段缺失不算冲突(旧记录没有艺术家)', () { + final d = MetadataStability.decide( + previous: { + 'title': '夜曲', + 'artist': '', + 'confidence': 0.7, + 'validation_count': 1, + }, + candidate: candidate(), + title: '夜曲', + artist: '周杰伦', + ); + expect(d.status, 'verified'); + expect(d.validationCount, 2); + }); + + test('大小写与首尾空格不影响一致性判断', () { + final d = MetadataStability.decide( + previous: { + 'title': ' Night Song ', + 'artist': 'JAY', + 'confidence': 0.8, + 'validation_count': 1, + }, + candidate: candidate(), + title: 'night song', + artist: 'jay', + ); + expect(d.status, 'verified'); + }); + }); +}