Files
QingTing-Player/lib/metadata/metadata_service.dart
T
lxh2875931338 18308d9d4e feat(metadata): 元数据分阶段探测(128KB → 512KB)+ song_key 改为纯 URL
渐进探测:
- FileProvider.getFile 增加 maxBytes 参数,WebDAV 按需只取头部
- MetadataService 先取 128KB 读标签;title/artist 都为空时再补到 512KB
- 缓存文件按上限区分(metadata_<hash>_128k.part / _512k.part)

song_key:
- 从 md5(url|size) 改为 md5(url)。分阶段探测的大小不同,
  带 size 会让同一首歌产生两个键;URL 在各阶段稳定。
- 代价:同 URL 换内容不自动失效,由清除单曲缓存/forceRefresh 兜底
- 数据库升到 v6,清空 songs / metadata_cache(可再生)

验证(release,真机):
  📦 取头部 128KB(上限 128KB,不再整文件下载)
  🧭 状态机: first_seen → pending(validations=1)
  📊 pending conf=0.92 evidence={...}
  ✅ saved: 两生关 - 谢安琪

测试:新增 song_key 3 个用例,累计 19 个全绿。
2026-10-24 20:28:50 +08:00

226 lines
8.0 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) {
return songKeyFor(url);
}
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. 分阶段取头部:先 128KB 拿 title/artist,读不到再补到 512KB。
// 弱网下首包更小,标签能更快出来,也少占带宽。
final provider = _getProvider(url);
var file = await provider.getFile(url, maxBytes: 128 * 1024);
if (file == null || !await file.exists()) {
debugPrint('⚠️ [MetadataService] file not available, using fallback');
return _fallbackFromFileName(fileName);
}
var raw = await _reader.readRawMetadata(file);
if (raw.title.isEmpty && raw.artist.isEmpty) {
debugPrint('📦 [MetadataService] 128KB 没读到标签,补取到 512KB');
final bigger = await provider.getFile(url, maxBytes: 512 * 1024);
if (bigger != null && await bigger.exists()) {
file = bigger;
raw = await _reader.readRawMetadata(file);
}
}
final stat = await file.stat();
final songKey = _generateSongKey(url);
// 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. 标准化(raw 已在上面分阶段读取)
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',
);
}
}