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 个全绿。
This commit is contained in:
2026-10-24 20:28:50 +08:00
parent b8138ad125
commit 18308d9d4e
6 changed files with 66 additions and 36 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ class SongDatabase {
_log('📂 数据库路径: $path');
return await openDatabase(
path,
version: 5, // v5:song_key 改为稳定的 md5
version: 6, // v6:song_key 改为纯 URL 的 md5(配合分阶段探测)
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -229,7 +229,7 @@ class SongDatabase {
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
if (oldVersion < 5) {
if (oldVersion < 6) {
await db.delete('metadata_cache');
await db.delete('songs');
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
+11 -9
View File
@@ -7,7 +7,8 @@ import '../services/webdav_service.dart';
import '../utils/song_key.dart';
abstract class FileProvider {
Future<File?> getFile(String url);
/// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。
Future<File?> getFile(String url, {int maxBytes = 512 * 1024});
}
class WebDAVFileProvider implements FileProvider {
@@ -18,7 +19,7 @@ class WebDAVFileProvider implements FileProvider {
/// 从访问日志看:2MB 的探测请求在这条链路上要 10~18 秒,
/// 期间 libmpv 的 206 吞吐会被压到 1Mbps 左右,所以压到 512KB。
/// 常见标签 + 封面都在这范围内;读不到时由上层在 80% 处重试。
static const int _maxBytes = 512 * 1024;
static const int defaultMaxBytes = 512 * 1024;
/// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。
static CancelToken? _activeToken;
@@ -26,10 +27,11 @@ class WebDAVFileProvider implements FileProvider {
final Dio _dio = Dio();
@override
Future<File?> getFile(String url) async {
Future<File?> getFile(String url, {int maxBytes = defaultMaxBytes}) async {
try {
final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.part';
final cachePath =
'${cacheDir.path}/metadata_${songKeyForUrl(url)}_${maxBytes ~/ 1024}k.part';
final cacheFile = File(cachePath);
// 1. 命中本地缓存(头部文件同样按 24h 过期)
@@ -57,7 +59,7 @@ class WebDAVFileProvider implements FileProvider {
options: Options(
headers: {
...headers,
'Range': 'bytes=0-${_maxBytes - 1}',
'Range': 'bytes=0-${maxBytes - 1}',
},
responseType: ResponseType.stream,
receiveTimeout: const Duration(seconds: 15),
@@ -74,13 +76,13 @@ class WebDAVFileProvider implements FileProvider {
var written = 0;
try {
await for (final chunk in body.stream) {
final remain = _maxBytes - written;
final remain = maxBytes - written;
if (remain <= 0) break;
final data =
chunk.lengthInBytes > remain ? chunk.sublist(0, remain) : chunk;
sink.add(data);
written += data.lengthInBytes;
if (written >= _maxBytes) break;
if (written >= maxBytes) break;
}
} finally {
await sink.flush();
@@ -96,7 +98,7 @@ class WebDAVFileProvider implements FileProvider {
debugPrint(
'📦 [WebDAVFileProvider] 取头部 ${(written / 1024).toStringAsFixed(0)}KB'
'(上限 ${_maxBytes ~/ 1024}KB,不再整文件下载)');
'(上限 ${maxBytes ~/ 1024}KB,不再整文件下载)');
return cacheFile;
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
@@ -120,7 +122,7 @@ class WebDAVFileProvider implements FileProvider {
class LocalFileProvider implements FileProvider {
@override
Future<File?> getFile(String url) async {
Future<File?> getFile(String url, {int maxBytes = 512 * 1024}) async {
final file = File(url);
if (await file.exists()) {
return file;
+17 -9
View File
@@ -22,8 +22,8 @@ class MetadataService {
final MetadataCache _memoryCache = MetadataCache();
final SongDatabase _db = SongDatabase();
String _generateSongKey(String url, int fileSize) {
return songKeyFor(url, fileSize);
String _generateSongKey(String url) {
return songKeyFor(url);
}
FileProvider _getProvider(String url) {
@@ -59,17 +59,28 @@ class MetadataService {
}
}
// 2. 获取文件
// 2. 分阶段取头部:先 128KB 拿 title/artist,读不到再补到 512KB。
// 弱网下首包更小,标签能更快出来,也少占带宽。
final provider = _getProvider(url);
final file = await provider.getFile(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, stat.size);
final songKey = _generateSongKey(url);
// 3. SQLite 查询:只有「已验证」的记录才直接返回。
// pending 记录会继续往下走,重新读取并累计 validation_count,
@@ -114,10 +125,7 @@ class MetadataService {
}
}
// 4. 读取原始 metadata
final raw = await _reader.readRawMetadata(file);
// 5. 标准化
// 4. 标准化(raw 已在上面分阶段读取)
final normalized = _normalizer.normalize(
raw,
fileName: fileName,
+3 -3
View File
@@ -660,8 +660,8 @@ class AudioService extends ChangeNotifier {
return {};
}
String _generateSongKey(String url, int fileSize) {
return songKeyFor(url, fileSize);
String _generateSongKey(String url) {
return songKeyFor(url);
}
// ════════════════════════════════════════════════════════════
@@ -759,7 +759,7 @@ class AudioService extends ChangeNotifier {
final file = await provider.getFile(url);
if (file != null && await file.exists()) {
final stat = await file.stat();
songKey = _generateSongKey(url, stat.size);
songKey = _generateSongKey(url);
} else {
songKey = songKeyForUrl(url);
}
+9 -13
View File
@@ -4,19 +4,15 @@ import 'package:crypto/crypto.dart';
/// 歌曲缓存键(song_key)。
///
/// 只使用「远程路径 + 文件大小」这类稳定信息:
/// - 不能用 String.hashCode:语义上不保证跨运行稳定(虽然当前 VM 实现是稳定的),
/// 用它做持久化键属于依赖未定义行为;
/// - 不能用本地临时文件的 mtime:WebDAV 的元数据预览文件会被系统清缓存、
/// 或超过 24h 后重新下载,mtime 每次都变,键跟着变 → 缓存永远 miss。
/// 只用 URL:元数据读取是"分阶段取头部"的,第一次可能只取 128KB、
/// 第二次补到 512KB。如果键里带文件大小,两次会得到不同的键,缓存立刻失效;
/// URL 在这些阶段之间是稳定的。
///
/// 因此这里用 md5(url|size)。文件被替换但大小不变时不会自动失效,
/// 由“清除单曲缓存 / forceRefresh”兜底。
String songKeyFor(String url, int fileSize) {
return md5.convert(utf8.encode('$url|$fileSize')).toString();
}
/// 只依赖 URL 的稳定键,用于临时文件命名、拿不到 stat 的场景。
String songKeyForUrl(String url) {
/// 代价:同一个 URL 换了文件内容时不会自动失效,由"清除单曲缓存 /
/// forceRefresh"兜底。
String songKeyFor(String url) {
return md5.convert(utf8.encode(url)).toString();
}
/// 语义化别名,用于临时文件命名等场景。
String songKeyForUrl(String url) => songKeyFor(url);