migrate artwork cache and fix mobile sheet layout
This commit is contained in:
@@ -38,6 +38,7 @@ class AppLogEntry {
|
||||
'Media' => '媒体库',
|
||||
'CloudIndex' => '全盘索引',
|
||||
'Player' => '播放器',
|
||||
'Storage' => '本地存储',
|
||||
'App' => '应用',
|
||||
'Flutter' => '界面',
|
||||
'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<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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user