上一条只加了字段和回调,listen 没插进去(列表不会刷新)。 另外歌曲元数据在 SQLite 命中时直接 return,也不会发事件—— 导致"标签其实早就在库里、列表却还显示文件名"。 - HomePage / WebDAVFileListPage 补上 stream.listen 与 cancel - MetadataService 的 SQLite 命中分支也 publish 一次(幂等,按 path 覆盖) 验证方式:播放一首收藏里显示文件名的歌 -> 返回主页 -> 该行应变为标签标题。
233 lines
8.3 KiB
Dart
233 lines
8.3 KiB
Dart
// 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',
|
||
);
|
||
}
|
||
}
|