引入部分缓存机制

引入封面图信息

正在进入测试阶段
This commit is contained in:
2026-08-25 22:13:36 +08:00
parent 7bd23bfee6
commit 0e8921742c
11 changed files with 358 additions and 75 deletions
+120
View File
@@ -0,0 +1,120 @@
// lib/database/song_database.dart
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
class SongDatabase {
static final SongDatabase _instance = SongDatabase._internal();
factory SongDatabase() => _instance;
SongDatabase._internal();
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'qingting_songs.db');
return await openDatabase(
path,
version: 1,
onCreate: _onCreate,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE songs (
song_key TEXT PRIMARY KEY,
remote_path TEXT,
file_size INTEGER,
modified_time INTEGER,
etag TEXT,
title TEXT,
artist TEXT,
album TEXT,
genre TEXT,
artwork_path TEXT,
confidence REAL DEFAULT 0.0,
validation_count INTEGER DEFAULT 0,
first_scan INTEGER,
last_scan INTEGER,
metadata_status TEXT DEFAULT 'pending'
)
''');
await db.execute('''
CREATE TABLE metadata_cache (
song_key TEXT PRIMARY KEY,
cache_path TEXT,
cache_created_at INTEGER,
cache_size INTEGER,
FOREIGN KEY (song_key) REFERENCES songs(song_key) ON DELETE CASCADE
)
''');
await db.execute('CREATE INDEX idx_songs_artist ON songs(artist)');
await db.execute('CREATE INDEX idx_songs_title ON songs(title)');
}
// ---- 查询 ----
Future<Map<String, dynamic>?> getSong(String songKey) async {
final db = await database;
final result =
await db.query('songs', where: 'song_key = ?', whereArgs: [songKey]);
return result.isNotEmpty ? result.first : null;
}
Future<List<Map<String, dynamic>>> getSongsByArtist(String artist,
{int limit = 20}) async {
if (artist.isEmpty) return [];
final db = await database;
return await db.query(
'songs',
where: 'artist = ?',
whereArgs: [artist],
limit: limit,
);
}
// ---- 写入 ----
Future<void> insertSong(Map<String, dynamic> song) async {
final db = await database;
await db.insert('songs', song,
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> updateSong(String songKey, Map<String, dynamic> updates) async {
final db = await database;
await db.update(
'songs',
updates,
where: 'song_key = ?',
whereArgs: [songKey],
);
}
// ---- 缓存 ----
Future<void> insertCache(Map<String, dynamic> cache) async {
final db = await database;
await db.insert('metadata_cache', cache,
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<Map<String, dynamic>?> getCache(String songKey) async {
final db = await database;
final result = await db
.query('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]);
return result.isNotEmpty ? result.first : null;
}
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+17 -17
View File
@@ -6,8 +6,8 @@ class RawMetadata {
final String title; final String title;
final String artist; final String artist;
final String album; final String album;
final List<String> genres; // ⬅️ 改为 List<String> final List<String> genres;
final List<String> performers; // ⬅️ 新增 final List<String> performers;
final int year; final int year;
final int trackNumber; final int trackNumber;
final int trackTotal; final int trackTotal;
@@ -83,13 +83,26 @@ class NormalizedMetadata {
bool get isNotEmpty => !isEmpty; bool get isNotEmpty => !isEmpty;
} }
/// 候选元数据
class CandidateMetadata {
final NormalizedMetadata metadata;
final double confidence;
final Map<String, double> evidence;
const CandidateMetadata({
required this.metadata,
required this.confidence,
this.evidence = const {},
});
}
/// 最终确认的元数据 /// 最终确认的元数据
class FinalMetadata { class FinalMetadata {
final String title; final String title;
final String artist; final String artist;
final String album; final String album;
final String genre; // ⬅️ 简化为单个流派(取第一个) final String genre;
final List<String> genres; // ⬅️ 保留完整列表 final List<String> genres;
final List<String> performers; final List<String> performers;
final int year; final int year;
final int trackNumber; final int trackNumber;
@@ -128,16 +141,3 @@ class FinalMetadata {
bool get isNotEmpty => !isEmpty; bool get isNotEmpty => !isEmpty;
} }
/// 候选元数据
class CandidateMetadata {
final NormalizedMetadata metadata;
final double confidence;
final Map<String, double> evidence;
const CandidateMetadata({
required this.metadata,
required this.confidence,
this.evidence = const {},
});
}
+1 -6
View File
@@ -4,19 +4,13 @@ import 'metadata_model.dart';
class MetadataNormalizer { class MetadataNormalizer {
NormalizedMetadata normalize(RawMetadata raw, NormalizedMetadata normalize(RawMetadata raw,
{String fileName = '', String filePath = ''}) { {String fileName = '', String filePath = ''}) {
// 1. 修剪
final title = raw.title.trim(); final title = raw.title.trim();
final artist = raw.artist.trim(); final artist = raw.artist.trim();
// 2. 如果 title 为空,从文件名推断
final finalTitle = final finalTitle =
title.isNotEmpty ? title : _inferTitleFromFileName(fileName); title.isNotEmpty ? title : _inferTitleFromFileName(fileName);
// 3. 如果 artist 为空,从路径推断
final finalArtist = final finalArtist =
artist.isNotEmpty ? artist : _inferArtistFromPath(filePath); artist.isNotEmpty ? artist : _inferArtistFromPath(filePath);
// 4. 如果 performers 不为空且 artist 为空,使用 performers
final finalArtist2 = finalArtist.isNotEmpty final finalArtist2 = finalArtist.isNotEmpty
? finalArtist ? finalArtist
: (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家'); : (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家');
@@ -57,6 +51,7 @@ class MetadataNormalizer {
CandidateMetadata evaluate( CandidateMetadata evaluate(
NormalizedMetadata normalized, List<Map<String, dynamic>> history) { NormalizedMetadata normalized, List<Map<String, dynamic>> history) {
// 第一版:直接接受,置信度 0.8
return CandidateMetadata( return CandidateMetadata(
metadata: normalized, metadata: normalized,
confidence: 0.8, confidence: 0.8,
+10 -3
View File
@@ -1,19 +1,25 @@
// lib/metadata/metadata_reader.dart // lib/metadata/metadata_reader.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; // ⭐ 添加 import 'package:flutter/foundation.dart';
import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr; import 'package:audio_metadata_reader/audio_metadata_reader.dart' as amr;
import 'metadata_model.dart'; import 'metadata_model.dart';
class MetadataReader { class MetadataReader {
Future<RawMetadata> readRawMetadata(File file) async { Future<RawMetadata> readRawMetadata(File file) async {
try { try {
final meta = amr.readMetadata(file, getImage: false); // ⭐ getImage: true 读取封面图
final meta = amr.readMetadata(file, getImage: true);
// ⭐ 提取第一张图片
Uint8List? artwork;
if (meta.pictures != null && meta.pictures!.isNotEmpty) {
artwork = meta.pictures.first.bytes;
}
return RawMetadata( return RawMetadata(
title: meta.title ?? '', title: meta.title ?? '',
artist: meta.artist ?? '', artist: meta.artist ?? '',
album: meta.album ?? '', album: meta.album ?? '',
// ⭐ genres 和 performers 是非空 List<String>,不需要 ??
genres: meta.genres, genres: meta.genres,
performers: meta.performers, performers: meta.performers,
year: _toInt(meta.year), year: _toInt(meta.year),
@@ -22,6 +28,7 @@ class MetadataReader {
discNumber: _toInt(meta.discNumber), discNumber: _toInt(meta.discNumber),
totalDisc: _toInt(meta.totalDisc), totalDisc: _toInt(meta.totalDisc),
duration: meta.duration, duration: meta.duration,
artwork: artwork,
); );
} catch (e) { } catch (e) {
debugPrint('⚠️ [MetadataReader] read failed: $e'); debugPrint('⚠️ [MetadataReader] read failed: $e');
+89 -17
View File
@@ -1,11 +1,14 @@
// lib/metadata/metadata_service.dart // lib/metadata/metadata_service.dart
// ⭐ 移除未使用的 import 'dart:io'; import 'dart:io'; // ⭐ 添加这一行
import 'package:flutter/foundation.dart'; // ⭐ 添加 import 'dart:typed_data'; // ⭐ 如果已经有更好
import 'package:flutter/foundation.dart';
import '../database/song_database.dart';
import 'metadata_model.dart'; import 'metadata_model.dart';
import 'metadata_reader.dart'; import 'metadata_reader.dart';
import 'metadata_normalizer.dart'; import 'metadata_normalizer.dart';
import 'metadata_cache.dart'; import 'metadata_cache.dart';
import 'file_provider.dart'; import 'file_provider.dart';
import '../utils/artwork_helper.dart';
class MetadataService { class MetadataService {
static final MetadataService _instance = MetadataService._internal(); static final MetadataService _instance = MetadataService._internal();
@@ -14,7 +17,13 @@ class MetadataService {
final MetadataReader _reader = MetadataReader(); final MetadataReader _reader = MetadataReader();
final MetadataNormalizer _normalizer = MetadataNormalizer(); final MetadataNormalizer _normalizer = MetadataNormalizer();
final MetadataCache _cache = MetadataCache(); 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();
}
FileProvider _getProvider(String url) { FileProvider _getProvider(String url) {
if (url.startsWith('http://') || url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
@@ -29,11 +38,11 @@ class MetadataService {
required String fileId, required String fileId,
bool forceRefresh = false, bool forceRefresh = false,
}) async { }) async {
// 1. 检查缓存 // 1. 内存缓存
if (!forceRefresh) { if (!forceRefresh) {
final cached = await _cache.get(fileId); final cached = await _memoryCache.get(fileId);
if (cached != null && cached.isNotEmpty) { if (cached != null && cached.isNotEmpty) {
debugPrint('📦 [MetadataService] cache hit: ${cached.title}'); debugPrint('📦 [MetadataService] memory hit: ${cached.title}');
return cached; return cached;
} }
} }
@@ -43,33 +52,96 @@ class MetadataService {
final file = await provider.getFile(url); final file = await provider.getFile(url);
if (file == null || !await file.exists()) { if (file == null || !await file.exists()) {
debugPrint( debugPrint('⚠️ [MetadataService] file not available, using fallback');
'⚠️ [MetadataService] file not available, using filename fallback');
return _fallbackFromFileName(fileName); return _fallbackFromFileName(fileName);
} }
// 3. 读取 metadata final stat = await file.stat();
final songKey =
_generateSongKey(url, stat.size, stat.modified.millisecondsSinceEpoch);
// 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); final raw = await _reader.readRawMetadata(file);
// 4. 标准化 // 5. 标准化
final normalized = _normalizer.normalize( final normalized = _normalizer.normalize(
raw, raw,
fileName: fileName, fileName: fileName,
filePath: url, filePath: url,
); );
// 5. 评估(第一阶段:直接接受) // 6. 评估
final history = await _cache.getHistory(normalized.artist, limit: 10); final history = await _db.getSongsByArtist(normalized.artist, limit: 10);
final candidate = _normalizer.evaluate(normalized, history); final candidate = _normalizer.evaluate(normalized, history);
final finalMetadata = _normalizer.decide(candidate); final finalMetadata = _normalizer.decide(candidate);
// 6. 缓存 // 7. 保存封面图到本地
if (finalMetadata.isNotEmpty) { String? artworkPath;
await _cache.put(fileId, finalMetadata); if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) {
debugPrint( artworkPath =
'✅ [MetadataService] metadata saved: ${finalMetadata.title} - ${finalMetadata.artist}'); await ArtworkHelper.saveArtwork(finalMetadata.artwork!, songKey);
} }
// 8. 保存到 SQLite
await _db.insertSong({
'song_key': songKey,
'remote_path': url,
'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; return finalMetadata;
} }
+10 -5
View File
@@ -154,12 +154,17 @@ class _PlayerPageState extends State<PlayerPage> {
spreadRadius: 10, spreadRadius: 10,
), ),
], ],
image: song.artwork != null
? DecorationImage(
image: MemoryImage(song.artwork!),
fit: BoxFit.cover,
)
: null,
), ),
child: const Icon( child: song.artwork == null
Icons.music_note, ? const Icon(Icons.music_note,
size: 64, size: 64, color: Colors.white24)
color: Colors.white24, : null,
),
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
Column( Column(
+49 -26
View File
@@ -1,5 +1,6 @@
// lib/services/audio_player_handler.dart // lib/services/audio_player_handler.dart
import 'dart:io'; // ⭐ 添加
import 'dart:typed_data'; // ⭐ 添加
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:audio_service/audio_service.dart' as audio_service; import 'package:audio_service/audio_service.dart' as audio_service;
import '../audio/player_controller.dart'; import '../audio/player_controller.dart';
@@ -53,22 +54,6 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
}); });
} }
void _updateMediaItemPosition(Duration position) {
final currentMediaItem = mediaItem.value;
if (currentMediaItem != null) {
mediaItem.add(audio_service.MediaItem(
id: currentMediaItem.id,
title: currentMediaItem.title,
artist: currentMediaItem.artist,
duration: currentMediaItem.duration,
extras: {
'position': position.inMilliseconds,
...?currentMediaItem.extras,
},
));
}
}
void _publishState() { void _publishState() {
final state = _state.playbackState; final state = _state.playbackState;
playbackState.add(audio_service.PlaybackState( playbackState.add(audio_service.PlaybackState(
@@ -111,38 +96,76 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
); );
} }
void updateNotification({
required String id,
required String title,
required String artist,
Uint8List? artwork,
}) {
_updateMediaItem(
id: id,
title: title,
artist: artist,
artwork: artwork,
);
}
void _updateMediaItem({ void _updateMediaItem({
required String id, required String id,
required String title, required String title,
required String artist, required String artist,
Duration? duration, Duration? duration,
Uint8List? artwork,
}) { }) {
_currentId = id; _currentId = id;
_currentTitle = title; _currentTitle = title;
_currentArtist = artist; _currentArtist = artist;
final position = _player.position; final position = _player.position;
debugPrint( debugPrint('📢 [handler] updateMediaItem: $title - $artist');
'📢 [handler] updateMediaItem: $title - $artist (position=${position.inSeconds}s)');
// ⭐ 如果有 artwork,保存为临时文件并设置 artUri
Uri? artUri;
if (artwork != null && artwork.isNotEmpty) {
try {
final tempDir = Directory.systemTemp;
final artPath = '${tempDir.path}/art_${id.hashCode}.jpg';
final artFile = File(artPath);
artFile.writeAsBytesSync(artwork);
artUri = Uri.file(artPath);
debugPrint('📢 [handler] artwork saved: $artPath');
} catch (e) {
debugPrint('⚠️ [handler] save artwork failed: $e');
}
}
mediaItem.add(audio_service.MediaItem( mediaItem.add(audio_service.MediaItem(
id: id, id: id,
title: title, title: title,
artist: artist, artist: artist,
duration: duration ?? _player.duration, duration: duration ?? _player.duration,
artUri: artUri,
extras: { extras: {
'position': position.inMilliseconds, 'position': position.inMilliseconds,
}, },
)); ));
} }
void updateNotification({ void _updateMediaItemPosition(Duration position) {
required String id, final currentMediaItem = mediaItem.value;
required String title, if (currentMediaItem != null) {
required String artist, mediaItem.add(audio_service.MediaItem(
}) { id: currentMediaItem.id,
_updateMediaItem(id: id, title: title, artist: artist); title: currentMediaItem.title,
_publishState(); artist: currentMediaItem.artist,
duration: currentMediaItem.duration,
artUri: currentMediaItem.artUri,
extras: {
'position': position.inMilliseconds,
...?currentMediaItem.extras,
},
));
}
} }
@override @override
+5
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'playback_service.dart'; import 'playback_service.dart';
import '../metadata/metadata_service.dart'; import '../metadata/metadata_service.dart';
import 'dart:typed_data';
enum PlayMode { enum PlayMode {
sequential, sequential,
@@ -16,12 +17,14 @@ class Song {
final String title; final String title;
final String artist; final String artist;
final String? url; final String? url;
final Uint8List? artwork;
Song({ Song({
required this.id, required this.id,
required this.title, required this.title,
required this.artist, required this.artist,
this.url, this.url,
this.artwork,
}); });
} }
@@ -181,6 +184,8 @@ class AudioService extends ChangeNotifier {
title: metadata.title.isNotEmpty ? metadata.title : song.title, title: metadata.title.isNotEmpty ? metadata.title : song.title,
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
url: song.url, url: song.url,
artwork: metadata.artwork,
// ⭐ 如果需要传递 artwork,可以在这里添加字段
); );
_queue[_currentIndex] = updatedSong; _queue[_currentIndex] = updatedSong;
_currentSong = updatedSong; _currentSong = updatedSong;
+55
View File
@@ -0,0 +1,55 @@
// lib/utils/artwork_helper.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
class ArtworkHelper {
static const String _artworkDir = 'artworks';
/// 保存封面图到本地
static Future<String?> saveArtwork(Uint8List data, String songKey) async {
try {
final dir = await getApplicationDocumentsDirectory();
final artworkDir = Directory('${dir.path}/$_artworkDir');
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
final path = '${artworkDir.path}/$songKey.jpg';
final file = File(path);
await file.writeAsBytes(data);
return path;
} catch (e) {
return null;
}
}
/// 获取封面图文件
static Future<File?> getArtwork(String songKey) async {
try {
final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/$_artworkDir/$songKey.jpg';
final file = File(path);
if (await file.exists()) {
return file;
}
return null;
} catch (e) {
return null;
}
}
/// 删除封面图
static Future<void> deleteArtwork(String songKey) async {
try {
final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/$_artworkDir/$songKey.jpg';
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
// ignore
}
}
}
+1 -1
View File
@@ -433,7 +433,7 @@ packages:
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+1
View File
@@ -43,6 +43,7 @@ dependencies:
sqflite: ^2.3.0 sqflite: ^2.3.0
audio_metadata_reader: ^1.7.1 audio_metadata_reader: ^1.7.1
path_provider: ^2.1.0 path_provider: ^2.1.0
path: ^1.9.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.