From 18308d9d4ed236b92a22919a1bc9d06adec27306 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 24 Oct 2026 20:28:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(metadata):=20=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=88=86=E9=98=B6=E6=AE=B5=E6=8E=A2=E6=B5=8B=EF=BC=88128KB=20?= =?UTF-8?q?=E2=86=92=20512KB=EF=BC=89+=20song=5Fkey=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=BA=AF=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 渐进探测: - FileProvider.getFile 增加 maxBytes 参数,WebDAV 按需只取头部 - MetadataService 先取 128KB 读标签;title/artist 都为空时再补到 512KB - 缓存文件按上限区分(metadata__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 个全绿。 --- lib/database/song_database.dart | 4 ++-- lib/metadata/file_provider.dart | 20 +++++++++++--------- lib/metadata/metadata_service.dart | 26 +++++++++++++++++--------- lib/services/audio_service.dart | 6 +++--- lib/utils/song_key.dart | 22 +++++++++------------- test/song_key_test.dart | 24 ++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 test/song_key_test.dart diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart index b321b6f..d13185d 100644 --- a/lib/database/song_database.dart +++ b/lib/database/song_database.dart @@ -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 算法升级)'); diff --git a/lib/metadata/file_provider.dart b/lib/metadata/file_provider.dart index e227bdc..8810688 100644 --- a/lib/metadata/file_provider.dart +++ b/lib/metadata/file_provider.dart @@ -7,7 +7,8 @@ import '../services/webdav_service.dart'; import '../utils/song_key.dart'; abstract class FileProvider { - Future getFile(String url); + /// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。 + Future 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 getFile(String url) async { + Future 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 getFile(String url) async { + Future getFile(String url, {int maxBytes = 512 * 1024}) async { final file = File(url); if (await file.exists()) { return file; diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index b0c4158..92c4c6b 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -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, diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index d150b2f..9473ff1 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -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); } diff --git a/lib/utils/song_key.dart b/lib/utils/song_key.dart index f150ffb..f7b4dd6 100644 --- a/lib/utils/song_key.dart +++ b/lib/utils/song_key.dart @@ -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); diff --git a/test/song_key_test.dart b/test/song_key_test.dart new file mode 100644 index 0000000..285e0d2 --- /dev/null +++ b/test/song_key_test.dart @@ -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'))); + }); + }); +}