migrate artwork cache and fix mobile sheet layout

This commit is contained in:
ngfchl
2026-07-19 11:40:37 +08:00
parent d762a1e4cc
commit 8b46330c46
4 changed files with 184 additions and 94 deletions
+1
View File
@@ -38,6 +38,7 @@ class AppLogEntry {
'Media' => '媒体库',
'CloudIndex' => '全盘索引',
'Player' => '播放器',
'Storage' => '本地存储',
'App' => '应用',
'Flutter' => '界面',
'Dart' => '运行时',
+91 -10
View File
@@ -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<Database>? _openingDatabase;
Future<Database> get _db async {
if (_database != null) return _database!;
Future<Database> get _db => _openDatabase();
Future<Database> _openDatabase() {
if (_database != null) return Future.value(_database!);
return _openingDatabase ??= _openDatabaseOnce();
}
Future<Database> _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<void> _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<void> _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<Set<String>> _tableColumns(
DatabaseExecutor db,
String schema,
+13
View File
@@ -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<void> _initializeMediaLibraryStorage() async {
AppLogger.info('Storage', '正在检查刮削数据库中的旧图片缓存');
await MediaLibraryStore().initialize();
AppLogger.info('Storage', '刮削数据库检查与压缩任务完成');
}
/// Startup network permission trigger
+79 -84
View File
@@ -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(