From 30111a5f0cd5b228c4fa4a9ed46c7478761903dd Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Fri, 23 Oct 2026 23:45:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(metadata):=20=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E7=AC=AC=202=20=E6=AD=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=BD=AE=E4=BF=A1=E5=BA=A6=E6=A8=A1=E5=9E=8B=20+=20pending/ver?= =?UTF-8?q?ified=20=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 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) --- lib/metadata/metadata_model.dart | 9 ++ lib/metadata/metadata_normalizer.dart | 110 +++++++++++++++++--- lib/metadata/metadata_service.dart | 91 ++++++++++++----- test/metadata_normalizer_test.dart | 140 ++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 test/metadata_normalizer_test.dart diff --git a/lib/metadata/metadata_model.dart b/lib/metadata/metadata_model.dart index af0716a..938b160 100644 --- a/lib/metadata/metadata_model.dart +++ b/lib/metadata/metadata_model.dart @@ -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 => diff --git a/lib/metadata/metadata_normalizer.dart b/lib/metadata/metadata_normalizer.dart index 2e082cb..7583bd1 100644 --- a/lib/metadata/metadata_normalizer.dart +++ b/lib/metadata/metadata_normalizer.dart @@ -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> history) { - // 第一版:直接接受,置信度 0.8 + final evidence = {}; + 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', ); } } diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index 7a21d4b..072782f 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -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? 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 = { '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) { diff --git a/test/metadata_normalizer_test.dart b/test/metadata_normalizer_test.dart new file mode 100644 index 0000000..6fbce2c --- /dev/null +++ b/test/metadata_normalizer_test.dart @@ -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'); + }); + }); +}