Files
QingTing-Player/lib/metadata/metadata_service.dart
T
lxh2875931338 b8138ad125 feat(metadata): 元数据引擎第 3 步之一——一致性与冲突感知的状态机
之前只要 confidence>=0.6 且 validation_count>=2 就升级 verified,
两次读到不同结果也会被当成"验证通过",错误标签一旦写入很难纠正。

新增 MetadataStability(纯函数,可单测):
- 首次见到 → pending(first_seen),次数 1
- 与上次记录标题/艺术家冲突 → pending(conflict),次数重置为 1
- 一致且次数 >= 2、置信度达标 → verified(stable)
- 置信度不足 → pending(low_confidence),次数继续累计
- 字段缺失不算冲突;比较时忽略大小写与首尾空格

metadata_service 改为调用该状态机,并打印判定原因:
  🧭 [MetadataService] 状态机: stable → verified(validations=2)

测试:新增 test/metadata_stability_test.dart 7 个用例,
连同原有 9 个共 16 个,flutter test 全绿。
已构建并安装 release 验证。
2026-10-24 20:24:55 +08:00

218 lines
7.5 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_service.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
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';
import '../utils/song_key.dart';
class MetadataService {
static final MetadataService _instance = MetadataService._internal();
factory MetadataService() => _instance;
MetadataService._internal();
final MetadataReader _reader = MetadataReader();
final MetadataNormalizer _normalizer = MetadataNormalizer();
final MetadataCache _memoryCache = MetadataCache();
final SongDatabase _db = SongDatabase();
String _generateSongKey(String url, int fileSize) {
return songKeyFor(url, fileSize);
}
FileProvider _getProvider(String url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return WebDAVFileProvider();
}
return LocalFileProvider();
}
/// 公开给 AudioService 使用(用于清除缓存时获取文件)
FileProvider getProviderForUrl(String url) {
return _getProvider(url);
}
/// 清除内存缓存
Future<void> clearCache(String fileId) async {
await _memoryCache.remove(fileId);
debugPrint('🗑️ [MetadataService] memory cache cleared: $fileId');
}
Future<FinalMetadata> getMetadata({
required String url,
required String fileName,
required String fileId,
bool forceRefresh = false,
}) async {
// 1. 内存缓存
if (!forceRefresh) {
final cached = await _memoryCache.get(fileId);
if (cached != null && cached.isNotEmpty) {
debugPrint('📦 [MetadataService] memory hit: ${cached.title}');
return cached;
}
}
// 2. 获取文件
final provider = _getProvider(url);
final file = await provider.getFile(url);
if (file == null || !await file.exists()) {
debugPrint('⚠️ [MetadataService] file not available, using fallback');
return _fallbackFromFileName(fileName);
}
final stat = await file.stat();
final songKey = _generateSongKey(url, stat.size);
// 3. SQLite 查询:只有「已验证」的记录才直接返回。
// pending 记录会继续往下走,重新读取并累计 validation_count,
// 这就是自修复:置信度不足的记录会在下次播放时被重新确认。
Map<String, dynamic>? dbSong;
if (!forceRefresh) {
dbSong = await _db.getSong(songKey);
if (dbSong != null) {
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
final status = dbSong['metadata_status'] as String? ?? 'pending';
if (confidence >= 0.6 && status == 'verified') {
debugPrint(
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
// 读取封面图
final artworkPath = dbSong['artwork_path'] as String?;
Uint8List? artwork;
if (artworkPath != null && artworkPath.isNotEmpty) {
try {
final artFile = File(artworkPath);
if (await artFile.exists()) {
artwork = await artFile.readAsBytes();
}
} catch (e) {
// ignore
}
}
final metadata = FinalMetadata(
title: dbSong['title'] as String? ?? '',
artist: dbSong['artist'] as String? ?? '',
album: dbSong['album'] as String? ?? '',
genre: dbSong['genre'] as String? ?? '',
artwork: artwork,
source: 'database',
);
await _memoryCache.put(fileId, metadata);
return metadata;
}
debugPrint('🔁 [MetadataService] 记录未验证'
'(conf=${confidence.toStringAsFixed(2)}, status=$status),重新确认');
}
}
// 4. 读取原始 metadata
final raw = await _reader.readRawMetadata(file);
// 5. 标准化
final normalized = _normalizer.normalize(
raw,
fileName: fileName,
filePath: url,
);
// 6. 评估(含历史一致性)
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
final candidate = _normalizer.evaluate(normalized, history);
final finalMetadata = _normalizer.decide(candidate);
// 7. 与已有记录合并(一致性 + 次数由状态机决定)
final prevConfidence = (dbSong?['confidence'] as num?)?.toDouble() ?? 0.0;
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;
// 8. 保存封面图(只有采用新结果且确实读到图时才写)
String? artworkPath = dbSong?['artwork_path'] as String?;
if (useNew &&
finalMetadata.artwork != null &&
finalMetadata.artwork!.isNotEmpty) {
artworkPath =
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
}
final status = decision.status;
debugPrint('🧭 [MetadataService] 状态机: ${decision.reason} → $status'
'(validations=$validationCount)');
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': 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': effectiveConfidence,
'validation_count': validationCount,
'first_scan':
dbSong?['first_scan'] ?? DateTime.now().millisecondsSinceEpoch,
'last_scan': DateTime.now().millisecondsSinceEpoch,
'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, merged);
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) {
final dotIndex = fileName.lastIndexOf('.');
final title = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName;
return FinalMetadata(
title: title,
artist: '未知艺术家',
source: 'filename',
);
}
}