Files
QingTing-Player/lib/metadata/metadata_service.dart
T
lxh2875931338 83ab47d8fc fix(metadata): 补上事件订阅,缓存命中也推送一次
上一条只加了字段和回调,listen 没插进去(列表不会刷新)。
另外歌曲元数据在 SQLite 命中时直接 return,也不会发事件——
导致"标签其实早就在库里、列表却还显示文件名"。

- HomePage / WebDAVFileListPage 补上 stream.listen 与 cancel
- MetadataService 的 SQLite 命中分支也 publish 一次(幂等,按 path 覆盖)

验证方式:播放一首收藏里显示文件名的歌 -> 返回主页 -> 该行应变为标签标题。
2026-11-01 12:22:46 +08:00

233 lines
8.3 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 '../services/metadata_events.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);
// 命中也推一次:列表页可能是元数据写入前加载的,
// 靠这次事件把那一行补上(幂等,订阅方按 path 覆盖)。
MetadataEvents().publish(fileId, dbSong);
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);
}
// 通知界面局部刷新(收藏/文件列表/歌单等)
MetadataEvents().publish(fileId, 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',
);
}
}