build+fix: debug 包独立为 .dev,并修复元数据缓存永远查不到

包名:
- debug 使用 com.lxh.qingting_player.dev(应用名"清听 Dev"),可与 release 并存
- release 保持 com.lxh.qingting_player
- Android 包名每一段必须以字母开头,因此用 .dev 而非 _dev
- AGP 9 需显式开启 buildFeatures.resValues;移除 manifest 中已废弃的 package 属性

元数据(两个独立原因叠加):
1. song_key 之前用 String.hashCode / 本地临时文件 mtime,都不稳定;
2. songs.remote_path 存的是完整 URL,而所有查询都用相对路径(song.id),
   getSongByPath 永远 miss,UI 只能回退到文件名。

- 新增 utils/song_key.dart:songKeyFor(url,size)=md5(url|size),跨进程稳定
- metadata_service: remote_path 改存 fileId(相对路径),并改用稳定 song_key
- audio_service / file_provider: 同步使用稳定键
- 数据库升级 v5:清空 songs / metadata_cache(可再生缓存;歌单与收藏按路径存储,不受影响)
- device-check.ps1: 支持 -Package,默认校验 dev 包,启动组件改为向 PackageManager 解析

验证:两次独立 dart 进程 song_key 输出一致;真机 debug/release 并存安装,单实例自检通过。
This commit is contained in:
2026-10-22 23:47:32 +08:00
parent 87011c7adc
commit 46a9add5cb
8 changed files with 77 additions and 25 deletions
+9 -1
View File
@@ -27,7 +27,7 @@ class SongDatabase {
_log('📂 数据库路径: $path');
return await openDatabase(
path,
version: 4, // 升级版本号
version: 5, // v5:song_key 改为稳定的 md5
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -226,6 +226,14 @@ class SongDatabase {
await _createPlaybackStateTable(db);
}
}
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
if (oldVersion < 5) {
await db.delete('metadata_cache');
await db.delete('songs');
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
}
_log('✅ 数据库升级完成');
}
+2 -1
View File
@@ -4,6 +4,7 @@ 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 {
Future<File?> getFile(String url);
@@ -17,7 +18,7 @@ class WebDAVFileProvider implements FileProvider {
try {
// 1. 获取缓存目录
final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
final cacheFile = File(cachePath);
// 2. 如果缓存存在且未过期,直接返回
+8 -6
View File
@@ -9,6 +9,7 @@ import 'metadata_normalizer.dart';
import 'metadata_cache.dart';
import 'file_provider.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
class MetadataService {
static final MetadataService _instance = MetadataService._internal();
@@ -20,9 +21,8 @@ class MetadataService {
final MetadataCache _memoryCache = MetadataCache();
final SongDatabase _db = SongDatabase();
String _generateSongKey(String url, int fileSize, int modifiedTime) {
final raw = '$url|$fileSize|$modifiedTime';
return raw.hashCode.toString();
String _generateSongKey(String url, int fileSize) {
return songKeyFor(url, fileSize);
}
FileProvider _getProvider(String url) {
@@ -68,8 +68,7 @@ class MetadataService {
}
final stat = await file.stat();
final songKey =
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
final songKey = _generateSongKey(url, stat.size);
// 3. SQLite 查询(高置信度直接使用)
if (!forceRefresh) {
@@ -133,9 +132,12 @@ class MetadataService {
}
// 8. 保存到 SQLite
// 注意:remote_path 必须存「应用内的歌曲路径」(即 fileId / song.id),
// 不能存完整 URL。playback_controller 与 AudioService 都是用相对路径
// 调 getSongByPath() 的,存 URL 会导致永远查不到、元数据看起来“没吃上”。
await _db.insertSong({
'song_key': songKey,
'remote_path': url,
'remote_path': fileId,
'file_size': stat.size,
'modified_time': stat.modified.millisecondsSinceEpoch,
'title': finalMetadata.title,
+7 -8
View File
@@ -10,6 +10,7 @@ import 'playback_service.dart';
import '../metadata/metadata_service.dart';
import '../database/song_database.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
import '../repositories/playlist_repository.dart';
import '../models/playlist.dart';
import 'webdav_service.dart';
@@ -351,9 +352,8 @@ class AudioService extends ChangeNotifier {
return {};
}
String _generateSongKey(String url, int fileSize, int modifiedTime) {
final raw = '$url|$fileSize|$modifiedTime';
return raw.hashCode.toString();
String _generateSongKey(String url, int fileSize) {
return songKeyFor(url, fileSize);
}
// ════════════════════════════════════════════════════════════
@@ -442,13 +442,12 @@ 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, stat.modified.millisecondsSinceEpoch);
songKey = _generateSongKey(url, stat.size);
} else {
songKey = url.hashCode.toString();
songKey = songKeyForUrl(url);
}
} catch (e) {
songKey = url.hashCode.toString();
songKey = songKeyForUrl(url);
}
final db = SongDatabase();
@@ -461,7 +460,7 @@ class AudioService extends ChangeNotifier {
try {
final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp';
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
final cacheFile = File(cachePath);
if (await cacheFile.exists()) {
await cacheFile.delete();
+22
View File
@@ -0,0 +1,22 @@
// lib/utils/song_key.dart
import 'dart:convert';
import 'package:crypto/crypto.dart';
/// 歌曲缓存键(song_key)。
///
/// 只使用「远程路径 + 文件大小」这类稳定信息:
/// - 不能用 String.hashCode:语义上不保证跨运行稳定(虽然当前 VM 实现是稳定的),
/// 用它做持久化键属于依赖未定义行为;
/// - 不能用本地临时文件的 mtime:WebDAV 的元数据预览文件会被系统清缓存、
/// 或超过 24h 后重新下载,mtime 每次都变,键跟着变 → 缓存永远 miss。
///
/// 因此这里用 md5(url|size)。文件被替换但大小不变时不会自动失效,
/// 由“清除单曲缓存 / forceRefresh”兜底。
String songKeyFor(String url, int fileSize) {
return md5.convert(utf8.encode('$url|$fileSize')).toString();
}
/// 只依赖 URL 的稳定键,用于临时文件命名、拿不到 stat 的场景。
String songKeyForUrl(String url) {
return md5.convert(utf8.encode(url)).toString();
}