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
+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;