包名: - 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 并存安装,单实例自检通过。
173 lines
5.7 KiB
Dart
173 lines
5.7 KiB
Dart
// lib/metadata/metadata_service.dart
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../database/song_database.dart';
|
|
import 'metadata_model.dart';
|
|
import 'metadata_reader.dart';
|
|
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();
|
|
factory MetadataService() => _instance;
|
|
MetadataService._internal();
|
|
|
|
final MetadataReader _reader = MetadataReader();
|
|
final MetadataNormalizer _normalizer = MetadataNormalizer();
|
|
final MetadataCache _memoryCache = MetadataCache();
|
|
final SongDatabase _db = SongDatabase();
|
|
|
|
String _generateSongKey(String url, int fileSize) {
|
|
return songKeyFor(url, fileSize);
|
|
}
|
|
|
|
FileProvider _getProvider(String url) {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
return WebDAVFileProvider();
|
|
}
|
|
return LocalFileProvider();
|
|
}
|
|
|
|
/// 公开给 AudioService 使用(用于清除缓存时获取文件)
|
|
FileProvider getProviderForUrl(String url) {
|
|
return _getProvider(url);
|
|
}
|
|
|
|
/// 清除内存缓存
|
|
Future<void> clearCache(String fileId) async {
|
|
await _memoryCache.remove(fileId);
|
|
debugPrint('🗑️ [MetadataService] memory cache cleared: $fileId');
|
|
}
|
|
|
|
Future<FinalMetadata> getMetadata({
|
|
required String url,
|
|
required String fileName,
|
|
required String fileId,
|
|
bool forceRefresh = false,
|
|
}) async {
|
|
// 1. 内存缓存
|
|
if (!forceRefresh) {
|
|
final cached = await _memoryCache.get(fileId);
|
|
if (cached != null && cached.isNotEmpty) {
|
|
debugPrint('📦 [MetadataService] memory hit: ${cached.title}');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
// 2. 获取文件
|
|
final provider = _getProvider(url);
|
|
final file = await provider.getFile(url);
|
|
|
|
if (file == null || !await file.exists()) {
|
|
debugPrint('⚠️ [MetadataService] file not available, using fallback');
|
|
return _fallbackFromFileName(fileName);
|
|
}
|
|
|
|
final stat = await file.stat();
|
|
final songKey = _generateSongKey(url, stat.size);
|
|
|
|
// 3. SQLite 查询(高置信度直接使用)
|
|
if (!forceRefresh) {
|
|
final dbSong = await _db.getSong(songKey);
|
|
if (dbSong != null) {
|
|
final confidence = (dbSong['confidence'] as num?)?.toDouble() ?? 0.0;
|
|
if (confidence >= 0.6) {
|
|
debugPrint(
|
|
'📦 [MetadataService] SQLite hit: ${dbSong['title']} - ${dbSong['artist']}');
|
|
|
|
// 读取封面图
|
|
final artworkPath = dbSong['artwork_path'] as String?;
|
|
Uint8List? artwork;
|
|
if (artworkPath != null && artworkPath.isNotEmpty) {
|
|
try {
|
|
final artFile = File(artworkPath);
|
|
if (await artFile.exists()) {
|
|
artwork = await artFile.readAsBytes();
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
final metadata = FinalMetadata(
|
|
title: dbSong['title'] as String? ?? '',
|
|
artist: dbSong['artist'] as String? ?? '',
|
|
album: dbSong['album'] as String? ?? '',
|
|
genre: dbSong['genre'] as String? ?? '',
|
|
artwork: artwork,
|
|
source: 'database',
|
|
);
|
|
await _memoryCache.put(fileId, metadata);
|
|
return metadata;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. 读取原始 metadata
|
|
final raw = await _reader.readRawMetadata(file);
|
|
|
|
// 5. 标准化
|
|
final normalized = _normalizer.normalize(
|
|
raw,
|
|
fileName: fileName,
|
|
filePath: url,
|
|
);
|
|
|
|
// 6. 评估
|
|
final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
|
|
final candidate = _normalizer.evaluate(normalized, history);
|
|
final finalMetadata = _normalizer.decide(candidate);
|
|
debugPrint(
|
|
'📢 [MetadataService] finalMetadata.artwork is ${finalMetadata.artwork != null ? 'not null (${finalMetadata.artwork!.length} bytes)' : 'null'}');
|
|
|
|
// 7. 保存封面图到本地
|
|
String? artworkPath;
|
|
if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
|
|
artworkPath =
|
|
await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
|
|
}
|
|
|
|
// 8. 保存到 SQLite
|
|
// 注意:remote_path 必须存「应用内的歌曲路径」(即 fileId / song.id),
|
|
// 不能存完整 URL。playback_controller 与 AudioService 都是用相对路径
|
|
// 调 getSongByPath() 的,存 URL 会导致永远查不到、元数据看起来“没吃上”。
|
|
await _db.insertSong({
|
|
'song_key': songKey,
|
|
'remote_path': fileId,
|
|
'file_size': stat.size,
|
|
'modified_time': stat.modified.millisecondsSinceEpoch,
|
|
'title': finalMetadata.title,
|
|
'artist': finalMetadata.artist,
|
|
'album': finalMetadata.album,
|
|
'genre': finalMetadata.genre,
|
|
'artwork_path': artworkPath,
|
|
'confidence': candidate.confidence,
|
|
'validation_count': 1,
|
|
'first_scan': DateTime.now().millisecondsSinceEpoch,
|
|
'last_scan': DateTime.now().millisecondsSinceEpoch,
|
|
'metadata_status': candidate.confidence >= 0.6 ? 'verified' : 'pending',
|
|
});
|
|
|
|
// 9. 内存缓存
|
|
await _memoryCache.put(fileId, finalMetadata);
|
|
|
|
debugPrint(
|
|
'✅ [MetadataService] saved: ${finalMetadata.title} - ${finalMetadata.artist} (confidence: ${candidate.confidence})');
|
|
return finalMetadata;
|
|
}
|
|
|
|
FinalMetadata _fallbackFromFileName(String fileName) {
|
|
final dotIndex = fileName.lastIndexOf('.');
|
|
final title = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName;
|
|
return FinalMetadata(
|
|
title: title,
|
|
artist: '未知艺术家',
|
|
source: 'filename',
|
|
);
|
|
}
|
|
}
|