diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index ea15e28..bf9ead3 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -38,6 +38,7 @@ class AppLogEntry { 'Media' => '媒体库', 'CloudIndex' => '全盘索引', 'Player' => '播放器', + 'Storage' => '本地存储', 'App' => '应用', 'Flutter' => '界面', 'Dart' => '运行时', diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index 6a13864..17abee5 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as path; import 'package:sqflite/sqflite.dart'; +import '../logging/app_logger.dart'; import '../../models/cloud_file.dart'; import '../../models/media_library.dart'; @@ -11,10 +12,17 @@ import '../../models/media_library.dart'; /// macOS client so its backup database can be merged without deserializing /// large poster/backdrop BLOBs in Dart. class MediaLibraryStore { - Database? _database; + static Database? _database; + static Future? _openingDatabase; - Future get _db async { - if (_database != null) return _database!; + Future get _db => _openDatabase(); + + Future _openDatabase() { + if (_database != null) return Future.value(_database!); + return _openingDatabase ??= _openDatabaseOnce(); + } + + Future _openDatabaseOnce() async { final databasePath = path.join( await getDatabasesPath(), 'media-library.sqlite3', @@ -33,7 +41,25 @@ class MediaLibraryStore { }, ); await _createSchema(_database!); - await _vacuumIfFragmented(_database!); + try { + final migration = await _migrateArtworkBlobSchema(_database!); + if (migration == null) { + AppLogger.info('Storage', '刮削数据库检查完成,未发现旧图片二进制缓存'); + } else { + AppLogger.info( + 'Storage', + '已迁移 ${migration.rows} 条刮削记录,移除 ${_formatBytes(migration.artworkBytes)} 图片二进制缓存', + ); + } + await _vacuumIfFragmented(_database!); + } catch (error, stackTrace) { + AppLogger.error( + 'Storage', + '刮削数据库迁移或压缩失败', + error: error, + stackTrace: stackTrace, + ); + } return _database!; } @@ -599,9 +625,38 @@ class MediaLibraryStore { await _ensureColumn(db, 'media_items', 'backdrop_path', 'TEXT'); } - Future _migrateArtworkBlobSchema(Database db) async { + Future<({int rows, int artworkBytes})?> _migrateArtworkBlobSchema( + Database db, + ) async { final columns = await _tableColumns(db, 'main', 'media_items'); - if (!columns.contains('poster') && !columns.contains('backdrop')) return; + if (!columns.contains('poster') && !columns.contains('backdrop')) { + return null; + } + final posterBytes = columns.contains('poster') + ? 'COALESCE(SUM(LENGTH(poster)), 0)' + : '0'; + final backdropBytes = columns.contains('backdrop') + ? 'COALESCE(SUM(LENGTH(backdrop)), 0)' + : '0'; + final artworkStats = await db.rawQuery(''' + SELECT + COUNT(*) AS rows, + $posterBytes + $backdropBytes AS artwork_bytes + FROM media_items + '''); + final rows = _asInt(artworkStats.firstOrNull?['rows']) ?? 0; + final artworkBytes = + _asInt(artworkStats.firstOrNull?['artwork_bytes']) ?? 0; + final posterPathSource = columns.contains('poster_path') + ? 'poster_path' + : 'NULL'; + final backdropPathSource = columns.contains('backdrop_path') + ? 'backdrop_path' + : 'NULL'; + AppLogger.info( + 'Storage', + '发现旧图片二进制缓存:$rows 条记录,${_formatBytes(artworkBytes)},正在重建精简表', + ); await db.transaction((txn) async { await txn.execute(''' CREATE TABLE media_items_compact ( @@ -639,7 +694,7 @@ class MediaLibraryStore { SELECT library_id, file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id, media_kind, title, original_title, release_date, - overview, poster_path, backdrop_path, has_chinese_audio, + overview, $posterPathSource, $backdropPathSource, has_chinese_audio, has_chinese_subtitle, collection_id, collection_name, updated_at FROM media_items '''); @@ -649,6 +704,7 @@ class MediaLibraryStore { ); await _createMediaItemIndexes(txn); }); + return (rows: rows, artworkBytes: artworkBytes); } Future _vacuumIfFragmented(Database db) async { @@ -656,12 +712,25 @@ class MediaLibraryStore { final freeList = await db.rawQuery('PRAGMA freelist_count'); final pages = _asInt(pageCount.firstOrNull?['page_count']) ?? 0; final free = _asInt(freeList.firstOrNull?['freelist_count']) ?? 0; - if (pages == 0 || free < 1024 || free / pages < 0.2) return; + if (pages == 0 || free < 1024 || free / pages < 0.2) { + AppLogger.info('Storage', '刮削数据库无需压缩:共 $pages 页,空闲 $free 页'); + return; + } + final before = await _databaseBytes(db.path); + AppLogger.info( + 'Storage', + '开始压缩刮削数据库:共 $pages 页,空闲 $free 页,当前 ${_formatBytes(before)}', + ); try { await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); await db.execute('VACUUM'); - } on DatabaseException { - // A failed optional compaction never prevents users from reading media. + final after = await _databaseBytes(db.path); + AppLogger.info( + 'Storage', + '刮削数据库压缩完成:${_formatBytes(before)} -> ${_formatBytes(after)},回收 ${_formatBytes((before - after).clamp(0, before))}', + ); + } on DatabaseException catch (error) { + AppLogger.warning('Storage', '刮削数据库压缩未完成:$error'); } } @@ -791,6 +860,18 @@ class MediaLibraryStore { return bytes; } + static String _formatBytes(int bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + final precision = unit == 0 || value >= 100 ? 0 : 1; + return '${value.toStringAsFixed(precision)} ${units[unit]}'; + } + Future> _tableColumns( DatabaseExecutor db, String schema, diff --git a/lib/main.dart b/lib/main.dart index 8a8f7c0..b9642ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:ui' show PlatformDispatcher; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'package:media_kit/media_kit.dart'; import 'core/storage/storage_manager.dart'; +import 'core/storage/media_library_store.dart'; import 'core/logging/app_logger.dart'; import 'core/http/dio_client.dart'; import 'app/app.dart'; @@ -51,6 +53,17 @@ void main() async { _triggerNetworkPermission(); runApp(const ProviderScope(child: GuangyaApp())); + + // Run legacy artwork cleanup once on every launch. It shares the database + // opening future with the media library, so entering the library while this + // runs cannot start another migration. + unawaited(_initializeMediaLibraryStorage()); +} + +Future _initializeMediaLibraryStorage() async { + AppLogger.info('Storage', '正在检查刮削数据库中的旧图片缓存'); + await MediaLibraryStore().initialize(); + AppLogger.info('Storage', '刮削数据库检查与压缩任务完成'); } /// Startup network permission trigger diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index e0a2e58..bb0f98d 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -922,7 +922,9 @@ class _MobileWorkspaceMenu extends StatelessWidget { constraints: BoxConstraints.tightFor(width: width), title: const Text('小黄鸭'), description: Text('$userName · $memberLevel'), - child: Column( + scrollable: false, + child: ListView( + padding: EdgeInsets.zero, children: [ Container( width: double.infinity, @@ -978,93 +980,86 @@ class _MobileWorkspaceMenu extends StatelessWidget { ), ), const SizedBox(height: 10), - Expanded( - child: ListView( + _MobileMenuGroup( + title: '常用入口', + children: [ + _MobileMenuRow( + icon: Icons.search_rounded, + label: '全局搜索', + onTap: onSearch, + ), + _MobileMenuRow( + icon: Icons.folder_rounded, + label: '文件管理', + onTap: () => onSection(WorkspaceSection.files), + ), + _MobileMenuRow( + icon: Icons.movie_rounded, + label: '光鸭影视', + onTap: () => onSection(WorkspaceSection.mediaLibrary), + ), + ], + ), + if (isCloud) + _MobileMenuGroup( + title: '文件内容', children: [ - _MobileMenuGroup( - title: '常用入口', - children: [ - _MobileMenuRow( - icon: Icons.search_rounded, - label: '全局搜索', - onTap: onSearch, - ), - _MobileMenuRow( - icon: Icons.folder_rounded, - label: '文件管理', - onTap: () => onSection(WorkspaceSection.files), - ), - _MobileMenuRow( - icon: Icons.movie_rounded, - label: '光鸭影视', - onTap: () => onSection(WorkspaceSection.mediaLibrary), - ), - ], - ), - if (isCloud) - _MobileMenuGroup( - title: '文件内容', - children: [ - for (final section in [ - WorkspaceSection.recentViewed, - WorkspaceSection.photos, - WorkspaceSection.videos, - WorkspaceSection.audio, - WorkspaceSection.documents, - WorkspaceSection.shares, - WorkspaceSection.recycle, - ]) - _MobileMenuRow( - icon: _mobileSectionIcon(section), - label: section.label, - onTap: () => onSection(section), - ), - ], + for (final section in [ + WorkspaceSection.recentViewed, + WorkspaceSection.photos, + WorkspaceSection.videos, + WorkspaceSection.audio, + WorkspaceSection.documents, + WorkspaceSection.shares, + WorkspaceSection.recycle, + ]) + _MobileMenuRow( + icon: _mobileSectionIcon(section), + label: section.label, + onTap: () => onSection(section), ), - _MobileMenuGroup( - title: isCloud ? '文件工具' : '影视工具', - children: [ - if (!isCloud) - _MobileMenuRow( - icon: Icons.add_rounded, - label: '新建媒体库', - onTap: onCreateLibrary, - ), - _MobileMenuRow( - icon: isCloud - ? Icons.manage_search_rounded - : Icons.movie_filter_rounded, - label: isCloud ? '文件扫描与清理' : '媒体库管理', - onTap: () => onTool( - isCloud ? WorkspaceTool.scan : WorkspaceTool.tmdb, - ), - ), - if (isCloud) ...[ - _MobileMenuRow( - icon: Icons.text_fields_rounded, - label: '批量重命名', - onTap: () => onTool(WorkspaceTool.rename), - ), - _MobileMenuRow( - icon: Icons.bolt_rounded, - label: '秒传工具', - onTap: () => onTool(WorkspaceTool.fastTransfer), - ), - ], - ], - ), - _MobileMenuGroup( - title: '系统维护', - children: [ - _MobileMenuRow( - icon: Icons.settings_rounded, - label: '设置中心', - onTap: onSettings, - ), - ], - ), ], ), + _MobileMenuGroup( + title: isCloud ? '文件工具' : '影视工具', + children: [ + if (!isCloud) + _MobileMenuRow( + icon: Icons.add_rounded, + label: '新建媒体库', + onTap: onCreateLibrary, + ), + _MobileMenuRow( + icon: isCloud + ? Icons.manage_search_rounded + : Icons.movie_filter_rounded, + label: isCloud ? '文件扫描与清理' : '媒体库管理', + onTap: () => + onTool(isCloud ? WorkspaceTool.scan : WorkspaceTool.tmdb), + ), + if (isCloud) ...[ + _MobileMenuRow( + icon: Icons.text_fields_rounded, + label: '批量重命名', + onTap: () => onTool(WorkspaceTool.rename), + ), + _MobileMenuRow( + icon: Icons.bolt_rounded, + label: '秒传工具', + onTap: () => onTool(WorkspaceTool.fastTransfer), + ), + ], + ], + ), + _MobileMenuGroup( + title: '系统维护', + children: [ + _MobileMenuRow( + icon: Icons.settings_rounded, + label: '设置中心', + onTap: onSettings, + ), + ], ), const ShadSeparator.horizontal(), _MobileMenuRow(