Files
QingTing-Player/lib/database/song_database.dart
T
lxh2875931338 70227afea5 feat(playlist): 歌单数据层 + 加入歌单入口
数据层:
- PlaylistSummary(歌单名/歌曲数/封面),DB 用一条 SQL 取摘要,
  封面取歌单内第一首有封面的歌
- PlaylistRepository.getSummaries() / addSongs()(批量加入并去重)
- 修复 AudioService.playPlaylist:之前把文件路径直接当 URL,
  歌单播放必然失败;现在用 WebDAVService.getFileUrl 构造

入口(视觉为临时实现,等设计稿替换):
- 文件列表长按 -> "加入歌单" 底部弹层
- 可在弹层里新建歌单并直接加入

真机验证:长按歌曲 -> 新建 "TL" -> 数据库 playlists/playlist_songs 均有记录。
测试:新增 playlist_summary 4 个用例,累计 29 个全绿。
2026-10-31 22:43:55 +08:00

560 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/database/song_database.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import '../services/audio_service.dart'; // 用于 PlayMode 枚举,但为了解耦我们传字符串
class SongDatabase {
static final SongDatabase _instance = SongDatabase._internal();
factory SongDatabase() => _instance;
SongDatabase._internal();
static Database? _database;
static bool enableLog = true;
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');
_log('📂 数据库路径: $path');
return await openDatabase(
path,
version: 6, // v6:song_key 改为纯 URL 的 md5(配合分阶段探测)
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
void _log(String message) {
if (enableLog) debugPrint('📦 [DB] $message');
}
void _logQuery(String table, String operation, {Map<String, dynamic>? args}) {
if (enableLog) {
final argsStr = args != null && args.isNotEmpty ? ' $args' : '';
debugPrint('📦 [DB] $table → $operation$argsStr');
}
}
Future<bool> _tableExists(Database db, String tableName) async {
final result = await db.query(
'sqlite_master',
where: 'type = ? AND name = ?',
whereArgs: ['table', tableName],
);
return result.isNotEmpty;
}
Future<bool> _columnExists(
Database db, String tableName, String columnName) async {
final result = await db.rawQuery('PRAGMA table_info($tableName)');
return result.any((col) => col['name'] == columnName);
}
Future<void> _onCreate(Database db, int version) async {
_log('🆕 创建数据库 (version $version)');
await _createSongsTable(db);
await _createMetadataCacheTable(db);
await _createPlaylistsTable(db);
await _createPlaylistSongsTable(db);
await _createFavoritesTable(db);
await _createPlaybackStateTable(db);
await _createIndexes(db);
_log('✅ 数据库创建完成');
}
Future<void> _createSongsTable(Database db) async {
await db.execute('''
CREATE TABLE songs (
song_key TEXT PRIMARY KEY,
remote_path TEXT,
content_hash 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'
)
''');
_log('📋 表创建: songs');
}
Future<void> _createMetadataCacheTable(Database db) async {
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
)
''');
_log('📋 表创建: metadata_cache');
}
Future<void> _createPlaylistsTable(Database db) async {
await db.execute('''
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
play_mode TEXT DEFAULT 'sequential',
created_at INTEGER,
updated_at INTEGER
)
''');
_log('📋 表创建: playlists');
}
Future<void> _createPlaylistSongsTable(Database db) async {
await db.execute('''
CREATE TABLE playlist_songs (
playlist_id TEXT,
song_id TEXT,
order_index INTEGER,
added_at INTEGER,
PRIMARY KEY (playlist_id, song_id),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)
''');
_log('📋 表创建: playlist_songs');
}
Future<void> _createFavoritesTable(Database db) async {
await db.execute('''
CREATE TABLE favorites (
song_id TEXT PRIMARY KEY,
favorited_at INTEGER,
FOREIGN KEY (song_id) REFERENCES songs(remote_path) ON DELETE CASCADE
)
''');
_log('📋 表创建: favorites');
}
Future<void> _createPlaybackStateTable(Database db) async {
await db.execute('''
CREATE TABLE playback_state (
id INTEGER PRIMARY KEY,
queue_json TEXT,
current_index INTEGER,
play_mode TEXT DEFAULT 'sequential',
current_playlist_id TEXT,
position_ms INTEGER DEFAULT 0,
updated_at INTEGER
)
''');
_log('📋 表创建: playback_state');
}
Future<void> _createIndexes(Database db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist)');
await db
.execute('CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title)');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_songs_content_hash ON songs(content_hash)');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id)');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_playlist_songs_order ON playlist_songs(order_index)');
_log('📋 索引创建完成');
}
// ════════════════════════════════════════════════════════════
// 升级逻辑(版本 3 → 4)
// ════════════════════════════════════════════════════════════
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
_log('⬆️ 数据库升级: $oldVersion → $newVersion');
if (oldVersion < 2) {
if (!await _columnExists(db, 'songs', 'content_hash')) {
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
_log('🔧 添加列: songs.content_hash');
}
final playlistsExists = await _tableExists(db, 'playlists');
if (!playlistsExists) {
await _createPlaylistsTable(db);
await _createPlaylistSongsTable(db);
await _createFavoritesTable(db);
} else {
if (!await _columnExists(db, 'playlists', 'play_mode')) {
await db.execute(
'ALTER TABLE playlists ADD COLUMN play_mode TEXT DEFAULT "sequential"');
_log('🔧 添加列: playlists.play_mode');
}
}
await _createIndexes(db);
}
if (oldVersion < 3) {
if (!await _columnExists(db, 'songs', 'content_hash')) {
await db.execute('ALTER TABLE songs ADD COLUMN content_hash TEXT');
_log('🔧 添加列: songs.content_hash');
}
final playlistsExists = await _tableExists(db, 'playlists');
if (!playlistsExists) {
await _createPlaylistsTable(db);
await _createPlaylistSongsTable(db);
await _createFavoritesTable(db);
} else {
if (!await _columnExists(db, 'playlists', 'play_mode')) {
await db.execute(
'ALTER TABLE playlists ADD COLUMN play_mode TEXT DEFAULT "sequential"');
_log('🔧 添加列: playlists.play_mode');
}
}
await _createIndexes(db);
}
// ⭐ 升级到版本 4:添加 playback_state 表
if (oldVersion < 4) {
final exists = await _tableExists(db, 'playback_state');
if (!exists) {
await _createPlaybackStateTable(db);
}
}
// ⭐ 升级到版本 5:song_key 由 String.hashCode 改为 md5(url|size|mtime)。
// 旧键带进程级随机种子、无法换算;songs / metadata_cache 都是可再生的缓存,
// 直接清空,下次播放时按需重建。歌单与收藏按路径(song_id)存储,不受影响。
if (oldVersion < 6) {
await db.delete('metadata_cache');
await db.delete('songs');
_log('🧹 已清空 songs / metadata_cache(song_key 算法升级)');
}
_log('✅ 数据库升级完成');
}
// ════════════════════════════════════════════════════════════
// 查询方法(原有)
// ════════════════════════════════════════════════════════════
Future<Map<String, dynamic>?> getSong(String songKey) async {
_logQuery('songs', 'get', args: {'song_key': songKey});
final db = await database;
final result =
await db.query('songs', where: 'song_key = ?', whereArgs: [songKey]);
return result.isNotEmpty ? result.first : null;
}
Future<Map<String, dynamic>?> getSongByPath(String remotePath) async {
_logQuery('songs', 'getByPath', args: {'remote_path': remotePath});
final db = await database;
final result = await db
.query('songs', where: 'remote_path = ?', whereArgs: [remotePath]);
return result.isNotEmpty ? result.first : null;
}
/// 批量按路径查元数据,返回 path -> row。
/// 文件列表页一次要显示几十上百首,逐条查会太慢。
Future<Map<String, Map<String, dynamic>>> getSongsByPaths(
List<String> paths) async {
final result = <String, Map<String, dynamic>>{};
if (paths.isEmpty) return result;
final db = await database;
const chunkSize = 400; // 避免 SQLite 变量数量上限
for (var i = 0; i < paths.length; i += chunkSize) {
final end = (i + chunkSize) > paths.length ? paths.length : i + chunkSize;
final slice = paths.sublist(i, end);
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await db.query(
'songs',
where: 'remote_path IN ($placeholders)',
whereArgs: slice,
);
for (final row in rows) {
final path = row['remote_path'] as String?;
if (path != null) result[path] = row;
}
}
_logQuery('songs', 'getByPaths', args: {'count': result.length});
return result;
}
Future<Map<String, dynamic>?> getSongByHash(String contentHash) async {
_logQuery('songs', 'getByHash', args: {'content_hash': contentHash});
final db = await database;
final result = await db
.query('songs', where: 'content_hash = ?', whereArgs: [contentHash]);
return result.isNotEmpty ? result.first : null;
}
Future<List<Map<String, dynamic>>> getSongsByArtist(String artist,
{int limit = 20}) async {
_logQuery('songs', 'getByArtist', args: {'artist': artist, 'limit': limit});
if (artist.isEmpty) return [];
final db = await database;
return await db.query(
'songs',
where: 'artist = ?',
whereArgs: [artist],
limit: limit,
);
}
// ════════════════════════════════════════════════════════════
// 播放列表 CRUD(原有)
// ════════════════════════════════════════════════════════════
Future<List<Map<String, dynamic>>> getAllPlaylists() async {
_logQuery('playlists', 'getAll');
final db = await database;
return await db.query('playlists', orderBy: 'created_at DESC');
}
/// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。
/// 列表页只需要这些,不用把每首歌都拉出来。
Future<List<Map<String, dynamic>>> getPlaylistSummaries() async {
final db = await database;
return await db.rawQuery('''
SELECT p.id, p.name, p.updated_at,
(SELECT COUNT(*) FROM playlist_songs ps
WHERE ps.playlist_id = p.id) AS song_count,
(SELECT s.artwork_path FROM playlist_songs ps2
JOIN songs s ON s.remote_path = ps2.song_id
WHERE ps2.playlist_id = p.id
AND s.artwork_path IS NOT NULL
AND s.artwork_path != ''
ORDER BY ps2.order_index
LIMIT 1) AS cover_path
FROM playlists p
ORDER BY p.updated_at DESC
''');
}
Future<Map<String, dynamic>?> getPlaylist(String id) async {
_logQuery('playlists', 'get', args: {'id': id});
final db = await database;
final result =
await db.query('playlists', where: 'id = ?', whereArgs: [id]);
return result.isNotEmpty ? result.first : null;
}
Future<void> insertPlaylist(Map<String, dynamic> playlist) async {
_logQuery('playlists', 'insert', args: {'name': playlist['name']});
final db = await database;
await db.insert('playlists', playlist,
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> updatePlaylist(String id, Map<String, dynamic> updates) async {
_logQuery('playlists', 'update',
args: {'id': id, 'updates': updates.keys.join(',')});
final db = await database;
await db.update('playlists', updates, where: 'id = ?', whereArgs: [id]);
}
Future<void> deletePlaylist(String id) async {
_logQuery('playlists', 'delete', args: {'id': id});
final db = await database;
await db.delete('playlists', where: 'id = ?', whereArgs: [id]);
}
// ════════════════════════════════════════════════════════════
// 播放列表歌曲(原有)
// ════════════════════════════════════════════════════════════
Future<List<Map<String, dynamic>>> getPlaylistSongs(String playlistId) async {
_logQuery('playlist_songs', 'get', args: {'playlist_id': playlistId});
final db = await database;
return await db.query(
'playlist_songs',
where: 'playlist_id = ?',
whereArgs: [playlistId],
orderBy: 'order_index ASC',
);
}
Future<void> addSongToPlaylist(String playlistId, String songId,
{int? orderIndex}) async {
_logQuery('playlist_songs', 'add',
args: {'playlist_id': playlistId, 'song_id': songId});
final db = await database;
int finalOrder = orderIndex ?? 0;
if (orderIndex == null) {
final existing = await db.query(
'playlist_songs',
where: 'playlist_id = ?',
whereArgs: [playlistId],
orderBy: 'order_index DESC',
limit: 1,
);
finalOrder =
existing.isNotEmpty ? (existing.first['order_index'] as int) + 1 : 0;
}
await db.insert(
'playlist_songs',
{
'playlist_id': playlistId,
'song_id': songId,
'order_index': finalOrder,
'added_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> removeSongFromPlaylist(String playlistId, String songId) async {
_logQuery('playlist_songs', 'remove',
args: {'playlist_id': playlistId, 'song_id': songId});
final db = await database;
await db.delete(
'playlist_songs',
where: 'playlist_id = ? AND song_id = ?',
whereArgs: [playlistId, songId],
);
}
Future<void> clearPlaylist(String playlistId) async {
_logQuery('playlist_songs', 'clear', args: {'playlist_id': playlistId});
final db = await database;
await db.delete('playlist_songs',
where: 'playlist_id = ?', whereArgs: [playlistId]);
}
// ════════════════════════════════════════════════════════════
// 收藏(原有)
// ════════════════════════════════════════════════════════════
Future<List<Map<String, dynamic>>> getFavorites() async {
_logQuery('favorites', 'getAll');
final db = await database;
return await db.query('favorites', orderBy: 'favorited_at DESC');
}
Future<bool> isFavorite(String songId) async {
_logQuery('favorites', 'isFavorite', args: {'song_id': songId});
final db = await database;
final result =
await db.query('favorites', where: 'song_id = ?', whereArgs: [songId]);
return result.isNotEmpty;
}
Future<void> addFavorite(String songId) async {
_logQuery('favorites', 'add', args: {'song_id': songId});
final db = await database;
await db.insert(
'favorites',
{
'song_id': songId,
'favorited_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> removeFavorite(String songId) async {
_logQuery('favorites', 'remove', args: {'song_id': songId});
final db = await database;
await db.delete('favorites', where: 'song_id = ?', whereArgs: [songId]);
}
Future<void> toggleFavorite(String songId) async {
if (await isFavorite(songId)) {
await removeFavorite(songId);
} else {
await addFavorite(songId);
}
}
// ════════════════════════════════════════════════════════════
// 原有方法(歌曲 CRUD)
// ════════════════════════════════════════════════════════════
Future<void> insertSong(Map<String, dynamic> song) async {
_logQuery('songs', 'insert', args: {'title': song['title']});
final db = await database;
await db.insert('songs', song,
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> updateSong(String songKey, Map<String, dynamic> updates) async {
_logQuery('songs', 'update',
args: {'song_key': songKey, 'fields': updates.keys.join(',')});
final db = await database;
await db
.update('songs', updates, where: 'song_key = ?', whereArgs: [songKey]);
}
Future<void> deleteSong(String songKey) async {
_logQuery('songs', 'delete', args: {'song_key': songKey});
final db = await database;
await db.delete('songs', where: 'song_key = ?', whereArgs: [songKey]);
}
Future<void> deleteCache(String songKey) async {
_logQuery('metadata_cache', 'delete', args: {'song_key': songKey});
final db = await database;
await db
.delete('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]);
}
Future<void> insertCache(Map<String, dynamic> cache) async {
_logQuery('metadata_cache', 'insert',
args: {'song_key': cache['song_key']});
final db = await database;
await db.insert('metadata_cache', cache,
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<Map<String, dynamic>?> getCache(String songKey) async {
_logQuery('metadata_cache', 'get', args: {'song_key': songKey});
final db = await database;
final result = await db
.query('metadata_cache', where: 'song_key = ?', whereArgs: [songKey]);
return result.isNotEmpty ? result.first : null;
}
// ════════════════════════════════════════════════════════════
// ⭐ 新增:播放状态持久化
// ════════════════════════════════════════════════════════════
/// 保存播放状态
Future<void> savePlaybackState({
required List<Map<String, dynamic>> queueJson, // 预先序列化的列表
required int currentIndex,
required String playMode,
String? currentPlaylistId,
required int positionMs,
}) async {
final db = await database;
await db.insert(
'playback_state',
{
'id': 1,
'queue_json': jsonEncode(queueJson),
'current_index': currentIndex,
'play_mode': playMode,
'current_playlist_id': currentPlaylistId,
'position_ms': positionMs,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
_log('💾 播放状态已保存');
}
/// 获取播放状态
Future<Map<String, dynamic>?> getPlaybackState() async {
final db = await database;
final result =
await db.query('playback_state', where: 'id = ?', whereArgs: [1]);
return result.isNotEmpty ? result.first : null;
}
Future<void> close() async {
_log('🔒 关闭数据库连接');
final db = await database;
await db.close();
_database = null;
}
}