diff --git a/lib/database/song_database.dart b/lib/database/song_database.dart new file mode 100644 index 0000000..237cd74 --- /dev/null +++ b/lib/database/song_database.dart @@ -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 get database async { + if (_database != null) return _database!; + _database = await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, 'qingting_songs.db'); + return await openDatabase( + path, + version: 1, + onCreate: _onCreate, + ); + } + + Future _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?> 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>> 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 insertSong(Map song) async { + final db = await database; + await db.insert('songs', song, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future updateSong(String songKey, Map updates) async { + final db = await database; + await db.update( + 'songs', + updates, + where: 'song_key = ?', + whereArgs: [songKey], + ); + } + + // ---- 缓存 ---- + Future insertCache(Map cache) async { + final db = await database; + await db.insert('metadata_cache', cache, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future?> 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 close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/metadata/metadata_model.dart b/lib/metadata/metadata_model.dart index 6a192f8..af0716a 100644 --- a/lib/metadata/metadata_model.dart +++ b/lib/metadata/metadata_model.dart @@ -6,8 +6,8 @@ class RawMetadata { final String title; final String artist; final String album; - final List genres; // ⬅️ 改为 List - final List performers; // ⬅️ 新增 + final List genres; + final List performers; final int year; final int trackNumber; final int trackTotal; @@ -83,13 +83,26 @@ class NormalizedMetadata { bool get isNotEmpty => !isEmpty; } +/// 候选元数据 +class CandidateMetadata { + final NormalizedMetadata metadata; + final double confidence; + final Map evidence; + + const CandidateMetadata({ + required this.metadata, + required this.confidence, + this.evidence = const {}, + }); +} + /// 最终确认的元数据 class FinalMetadata { final String title; final String artist; final String album; - final String genre; // ⬅️ 简化为单个流派(取第一个) - final List genres; // ⬅️ 保留完整列表 + final String genre; + final List genres; final List performers; final int year; final int trackNumber; @@ -128,16 +141,3 @@ class FinalMetadata { bool get isNotEmpty => !isEmpty; } - -/// 候选元数据 -class CandidateMetadata { - final NormalizedMetadata metadata; - final double confidence; - final Map evidence; - - const CandidateMetadata({ - required this.metadata, - required this.confidence, - this.evidence = const {}, - }); -} diff --git a/lib/metadata/metadata_normalizer.dart b/lib/metadata/metadata_normalizer.dart index 07f94bf..2e082cb 100644 --- a/lib/metadata/metadata_normalizer.dart +++ b/lib/metadata/metadata_normalizer.dart @@ -4,19 +4,13 @@ import 'metadata_model.dart'; class MetadataNormalizer { NormalizedMetadata normalize(RawMetadata raw, {String fileName = '', String filePath = ''}) { - // 1. 修剪 final title = raw.title.trim(); final artist = raw.artist.trim(); - // 2. 如果 title 为空,从文件名推断 final finalTitle = title.isNotEmpty ? title : _inferTitleFromFileName(fileName); - - // 3. 如果 artist 为空,从路径推断 final finalArtist = artist.isNotEmpty ? artist : _inferArtistFromPath(filePath); - - // 4. 如果 performers 不为空且 artist 为空,使用 performers final finalArtist2 = finalArtist.isNotEmpty ? finalArtist : (raw.performers.isNotEmpty ? raw.performers.first : '未知艺术家'); @@ -57,6 +51,7 @@ class MetadataNormalizer { CandidateMetadata evaluate( NormalizedMetadata normalized, List> history) { + // 第一版:直接接受,置信度 0.8 return CandidateMetadata( metadata: normalized, confidence: 0.8, diff --git a/lib/metadata/metadata_reader.dart b/lib/metadata/metadata_reader.dart index 1863026..2d579c3 100644 --- a/lib/metadata/metadata_reader.dart +++ b/lib/metadata/metadata_reader.dart @@ -1,19 +1,25 @@ // lib/metadata/metadata_reader.dart 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 'metadata_model.dart'; class MetadataReader { Future readRawMetadata(File file) async { 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( title: meta.title ?? '', artist: meta.artist ?? '', album: meta.album ?? '', - // ⭐ genres 和 performers 是非空 List,不需要 ?? genres: meta.genres, performers: meta.performers, year: _toInt(meta.year), @@ -22,6 +28,7 @@ class MetadataReader { discNumber: _toInt(meta.discNumber), totalDisc: _toInt(meta.totalDisc), duration: meta.duration, + artwork: artwork, ); } catch (e) { debugPrint('⚠️ [MetadataReader] read failed: $e'); diff --git a/lib/metadata/metadata_service.dart b/lib/metadata/metadata_service.dart index fe41529..23bf673 100644 --- a/lib/metadata/metadata_service.dart +++ b/lib/metadata/metadata_service.dart @@ -1,11 +1,14 @@ // lib/metadata/metadata_service.dart -// ⭐ 移除未使用的 import 'dart:io'; -import 'package:flutter/foundation.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'; class MetadataService { static final MetadataService _instance = MetadataService._internal(); @@ -14,7 +17,13 @@ class MetadataService { final MetadataReader _reader = MetadataReader(); 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) { if (url.startsWith('http://') || url.startsWith('https://')) { @@ -29,11 +38,11 @@ class MetadataService { required String fileId, bool forceRefresh = false, }) async { - // 1. 检查缓存 + // 1. 内存缓存 if (!forceRefresh) { - final cached = await _cache.get(fileId); + final cached = await _memoryCache.get(fileId); if (cached != null && cached.isNotEmpty) { - debugPrint('📦 [MetadataService] cache hit: ${cached.title}'); + debugPrint('📦 [MetadataService] memory hit: ${cached.title}'); return cached; } } @@ -43,33 +52,96 @@ class MetadataService { final file = await provider.getFile(url); if (file == null || !await file.exists()) { - debugPrint( - '⚠️ [MetadataService] file not available, using filename fallback'); + debugPrint('⚠️ [MetadataService] file not available, using fallback'); 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); - // 4. 标准化 + // 5. 标准化 final normalized = _normalizer.normalize( raw, fileName: fileName, filePath: url, ); - // 5. 评估(第一阶段:直接接受) - final history = await _cache.getHistory(normalized.artist, limit: 10); + // 6. 评估 + final history = await _db.getSongsByArtist(normalized.artist, limit: 10); final candidate = _normalizer.evaluate(normalized, history); final finalMetadata = _normalizer.decide(candidate); - // 6. 缓存 - if (finalMetadata.isNotEmpty) { - await _cache.put(fileId, finalMetadata); - debugPrint( - '✅ [MetadataService] metadata saved: ${finalMetadata.title} - ${finalMetadata.artist}'); + // 7. 保存封面图到本地 + String? artworkPath; + if (finalMetadata.artwork != null && finalMetadata.artwork!.isNotEmpty) { + artworkPath = + 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; } diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index 68475e9..c629ee6 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -154,12 +154,17 @@ class _PlayerPageState extends State { spreadRadius: 10, ), ], + image: song.artwork != null + ? DecorationImage( + image: MemoryImage(song.artwork!), + fit: BoxFit.cover, + ) + : null, ), - child: const Icon( - Icons.music_note, - size: 64, - color: Colors.white24, - ), + child: song.artwork == null + ? const Icon(Icons.music_note, + size: 64, color: Colors.white24) + : null, ), const SizedBox(height: 48), Column( diff --git a/lib/services/audio_player_handler.dart b/lib/services/audio_player_handler.dart index f2ba0e9..5a91f56 100644 --- a/lib/services/audio_player_handler.dart +++ b/lib/services/audio_player_handler.dart @@ -1,5 +1,6 @@ // lib/services/audio_player_handler.dart - +import 'dart:io'; // ⭐ 添加 +import 'dart:typed_data'; // ⭐ 添加 import 'package:flutter/foundation.dart'; import 'package:audio_service/audio_service.dart' as audio_service; 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() { final state = _state.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({ required String id, required String title, required String artist, Duration? duration, + Uint8List? artwork, }) { _currentId = id; _currentTitle = title; _currentArtist = artist; final position = _player.position; - debugPrint( - '📢 [handler] updateMediaItem: $title - $artist (position=${position.inSeconds}s)'); + debugPrint('📢 [handler] updateMediaItem: $title - $artist'); + + // ⭐ 如果有 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( id: id, title: title, artist: artist, duration: duration ?? _player.duration, + artUri: artUri, extras: { 'position': position.inMilliseconds, }, )); } - void updateNotification({ - required String id, - required String title, - required String artist, - }) { - _updateMediaItem(id: id, title: title, artist: artist); - _publishState(); + 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, + artUri: currentMediaItem.artUri, + extras: { + 'position': position.inMilliseconds, + ...?currentMediaItem.extras, + }, + )); + } } @override diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 1bf4d27..cea1f84 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'playback_service.dart'; import '../metadata/metadata_service.dart'; +import 'dart:typed_data'; enum PlayMode { sequential, @@ -16,12 +17,14 @@ class Song { final String title; final String artist; final String? url; + final Uint8List? artwork; Song({ required this.id, required this.title, required this.artist, this.url, + this.artwork, }); } @@ -181,6 +184,8 @@ class AudioService extends ChangeNotifier { title: metadata.title.isNotEmpty ? metadata.title : song.title, artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, url: song.url, + artwork: metadata.artwork, + // ⭐ 如果需要传递 artwork,可以在这里添加字段 ); _queue[_currentIndex] = updatedSong; _currentSong = updatedSong; diff --git a/lib/utils/artwork_helper.dart b/lib/utils/artwork_helper.dart new file mode 100644 index 0000000..c111967 --- /dev/null +++ b/lib/utils/artwork_helper.dart @@ -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 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 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 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 + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 60d0ce9..ed680dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -433,7 +433,7 @@ packages: source: hosted version: "3.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index c78b3e6..0990ae1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: sqflite: ^2.3.0 audio_metadata_reader: ^1.7.1 path_provider: ^2.1.0 + path: ^1.9.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.