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:
@@ -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 算法升级)');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// test/song_key_test.dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:qingting_player/utils/song_key.dart';
|
||||
|
||||
void main() {
|
||||
group('songKeyFor', () {
|
||||
test('同一 URL 多次计算结果一致(跨进程稳定)', () {
|
||||
const url = 'https://example.com/dav/music/a.flac';
|
||||
expect(songKeyFor(url), songKeyFor(url));
|
||||
});
|
||||
|
||||
test('与探测大小无关:不同阶段的头部请求得到同一个键', () {
|
||||
// 键只依赖 URL,因此 128KB / 512KB 两次探测不会产生两个缓存键
|
||||
const url = 'https://example.com/dav/music/b.flac';
|
||||
final key = songKeyFor(url);
|
||||
expect(key, songKeyForUrl(url));
|
||||
expect(key.length, 32); // md5 十六进制
|
||||
});
|
||||
|
||||
test('不同 URL 得到不同键', () {
|
||||
expect(songKeyFor('https://x/a.flac'), isNot(songKeyFor('https://x/b.flac')));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user