渐进探测: - 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 个全绿。
133 lines
4.3 KiB
Dart
133 lines
4.3 KiB
Dart
// lib/metadata/file_provider.dart
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:dio/dio.dart';
|
|
import '../services/webdav_service.dart';
|
|
import '../utils/song_key.dart';
|
|
|
|
abstract class FileProvider {
|
|
/// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。
|
|
Future<File?> getFile(String url, {int maxBytes = 512 * 1024});
|
|
}
|
|
|
|
class WebDAVFileProvider implements FileProvider {
|
|
/// 只为读标签取文件头部,绝不整文件下载。
|
|
/// 之前用 dio.download 会把整首歌拉到临时目录,和 mpv 的流式播放抢带宽,
|
|
/// 20Mbps 的 FRP 链路上尤其明显(表现为流量很高但缓冲一直为 0)。
|
|
///
|
|
/// 从访问日志看:2MB 的探测请求在这条链路上要 10~18 秒,
|
|
/// 期间 libmpv 的 206 吞吐会被压到 1Mbps 左右,所以压到 512KB。
|
|
/// 常见标签 + 封面都在这范围内;读不到时由上层在 80% 处重试。
|
|
static const int defaultMaxBytes = 512 * 1024;
|
|
|
|
/// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。
|
|
static CancelToken? _activeToken;
|
|
|
|
final Dio _dio = Dio();
|
|
|
|
@override
|
|
Future<File?> getFile(String url, {int maxBytes = defaultMaxBytes}) async {
|
|
try {
|
|
final cacheDir = await getTemporaryDirectory();
|
|
final cachePath =
|
|
'${cacheDir.path}/metadata_${songKeyForUrl(url)}_${maxBytes ~/ 1024}k.part';
|
|
final cacheFile = File(cachePath);
|
|
|
|
// 1. 命中本地缓存(头部文件同样按 24h 过期)
|
|
if (await cacheFile.exists()) {
|
|
final stat = await cacheFile.stat();
|
|
if (stat.size > 0 &&
|
|
DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
|
return cacheFile;
|
|
}
|
|
}
|
|
|
|
// 2. 认证头
|
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
|
if (headers.isEmpty) return null;
|
|
|
|
// 3. 取消上一次未完成的下载
|
|
_activeToken?.cancel('superseded');
|
|
final token = CancelToken();
|
|
_activeToken = token;
|
|
|
|
// 4. 只请求前 2MB
|
|
final response = await _dio.get<ResponseBody>(
|
|
url,
|
|
cancelToken: token,
|
|
options: Options(
|
|
headers: {
|
|
...headers,
|
|
'Range': 'bytes=0-${maxBytes - 1}',
|
|
},
|
|
responseType: ResponseType.stream,
|
|
receiveTimeout: const Duration(seconds: 15),
|
|
sendTimeout: const Duration(seconds: 10),
|
|
followRedirects: true,
|
|
),
|
|
);
|
|
|
|
final body = response.data;
|
|
if (body == null) return null;
|
|
|
|
// 5. 写入临时文件(最多 _maxBytes)
|
|
final sink = cacheFile.openWrite();
|
|
var written = 0;
|
|
try {
|
|
await for (final chunk in body.stream) {
|
|
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;
|
|
}
|
|
} finally {
|
|
await sink.flush();
|
|
await sink.close();
|
|
}
|
|
|
|
if (written == 0) {
|
|
try {
|
|
await cacheFile.delete();
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
debugPrint(
|
|
'📦 [WebDAVFileProvider] 取头部 ${(written / 1024).toStringAsFixed(0)}KB'
|
|
'(上限 ${maxBytes ~/ 1024}KB,不再整文件下载)');
|
|
return cacheFile;
|
|
} on DioException catch (e) {
|
|
if (CancelToken.isCancel(e)) {
|
|
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
|
|
return null;
|
|
}
|
|
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
|
|
static void cancelActive() {
|
|
_activeToken?.cancel('superseded');
|
|
_activeToken = null;
|
|
}
|
|
}
|
|
|
|
class LocalFileProvider implements FileProvider {
|
|
@override
|
|
Future<File?> getFile(String url, {int maxBytes = 512 * 1024}) async {
|
|
final file = File(url);
|
|
if (await file.exists()) {
|
|
return file;
|
|
}
|
|
return null;
|
|
}
|
|
}
|