migrate artwork cache and fix mobile sheet layout
This commit is contained in:
@@ -38,6 +38,7 @@ class AppLogEntry {
|
|||||||
'Media' => '媒体库',
|
'Media' => '媒体库',
|
||||||
'CloudIndex' => '全盘索引',
|
'CloudIndex' => '全盘索引',
|
||||||
'Player' => '播放器',
|
'Player' => '播放器',
|
||||||
|
'Storage' => '本地存储',
|
||||||
'App' => '应用',
|
'App' => '应用',
|
||||||
'Flutter' => '界面',
|
'Flutter' => '界面',
|
||||||
'Dart' => '运行时',
|
'Dart' => '运行时',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
import '../logging/app_logger.dart';
|
||||||
import '../../models/cloud_file.dart';
|
import '../../models/cloud_file.dart';
|
||||||
import '../../models/media_library.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
|
/// macOS client so its backup database can be merged without deserializing
|
||||||
/// large poster/backdrop BLOBs in Dart.
|
/// large poster/backdrop BLOBs in Dart.
|
||||||
class MediaLibraryStore {
|
class MediaLibraryStore {
|
||||||
Database? _database;
|
static Database? _database;
|
||||||
|
static Future<Database>? _openingDatabase;
|
||||||
|
|
||||||
Future<Database> get _db async {
|
Future<Database> get _db => _openDatabase();
|
||||||
if (_database != null) return _database!;
|
|
||||||
|
Future<Database> _openDatabase() {
|
||||||
|
if (_database != null) return Future.value(_database!);
|
||||||
|
return _openingDatabase ??= _openDatabaseOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _openDatabaseOnce() async {
|
||||||
final databasePath = path.join(
|
final databasePath = path.join(
|
||||||
await getDatabasesPath(),
|
await getDatabasesPath(),
|
||||||
'media-library.sqlite3',
|
'media-library.sqlite3',
|
||||||
@@ -33,7 +41,25 @@ class MediaLibraryStore {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await _createSchema(_database!);
|
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!;
|
return _database!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,9 +625,38 @@ class MediaLibraryStore {
|
|||||||
await _ensureColumn(db, 'media_items', 'backdrop_path', 'TEXT');
|
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');
|
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 db.transaction((txn) async {
|
||||||
await txn.execute('''
|
await txn.execute('''
|
||||||
CREATE TABLE media_items_compact (
|
CREATE TABLE media_items_compact (
|
||||||
@@ -639,7 +694,7 @@ class MediaLibraryStore {
|
|||||||
SELECT
|
SELECT
|
||||||
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||||
file_type, tmdb_id, media_kind, title, original_title, release_date,
|
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
|
has_chinese_subtitle, collection_id, collection_name, updated_at
|
||||||
FROM media_items
|
FROM media_items
|
||||||
''');
|
''');
|
||||||
@@ -649,6 +704,7 @@ class MediaLibraryStore {
|
|||||||
);
|
);
|
||||||
await _createMediaItemIndexes(txn);
|
await _createMediaItemIndexes(txn);
|
||||||
});
|
});
|
||||||
|
return (rows: rows, artworkBytes: artworkBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _vacuumIfFragmented(Database db) async {
|
Future<void> _vacuumIfFragmented(Database db) async {
|
||||||
@@ -656,12 +712,25 @@ class MediaLibraryStore {
|
|||||||
final freeList = await db.rawQuery('PRAGMA freelist_count');
|
final freeList = await db.rawQuery('PRAGMA freelist_count');
|
||||||
final pages = _asInt(pageCount.firstOrNull?['page_count']) ?? 0;
|
final pages = _asInt(pageCount.firstOrNull?['page_count']) ?? 0;
|
||||||
final free = _asInt(freeList.firstOrNull?['freelist_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 {
|
try {
|
||||||
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
await db.execute('VACUUM');
|
await db.execute('VACUUM');
|
||||||
} on DatabaseException {
|
final after = await _databaseBytes(db.path);
|
||||||
// A failed optional compaction never prevents users from reading media.
|
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;
|
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(
|
Future<Set<String>> _tableColumns(
|
||||||
DatabaseExecutor db,
|
DatabaseExecutor db,
|
||||||
String schema,
|
String schema,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui' show PlatformDispatcher;
|
import 'dart:ui' show PlatformDispatcher;
|
||||||
import 'package:flutter/material.dart';
|
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:window_manager/window_manager.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'core/storage/storage_manager.dart';
|
import 'core/storage/storage_manager.dart';
|
||||||
|
import 'core/storage/media_library_store.dart';
|
||||||
import 'core/logging/app_logger.dart';
|
import 'core/logging/app_logger.dart';
|
||||||
import 'core/http/dio_client.dart';
|
import 'core/http/dio_client.dart';
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
@@ -51,6 +53,17 @@ void main() async {
|
|||||||
_triggerNetworkPermission();
|
_triggerNetworkPermission();
|
||||||
|
|
||||||
runApp(const ProviderScope(child: GuangyaApp()));
|
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
|
/// Startup network permission trigger
|
||||||
|
|||||||
@@ -922,7 +922,9 @@ class _MobileWorkspaceMenu extends StatelessWidget {
|
|||||||
constraints: BoxConstraints.tightFor(width: width),
|
constraints: BoxConstraints.tightFor(width: width),
|
||||||
title: const Text('小黄鸭'),
|
title: const Text('小黄鸭'),
|
||||||
description: Text('$userName · $memberLevel'),
|
description: Text('$userName · $memberLevel'),
|
||||||
child: Column(
|
scrollable: false,
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -978,93 +980,86 @@ class _MobileWorkspaceMenu extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Expanded(
|
_MobileMenuGroup(
|
||||||
child: ListView(
|
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: [
|
children: [
|
||||||
_MobileMenuGroup(
|
for (final section in [
|
||||||
title: '常用入口',
|
WorkspaceSection.recentViewed,
|
||||||
children: [
|
WorkspaceSection.photos,
|
||||||
_MobileMenuRow(
|
WorkspaceSection.videos,
|
||||||
icon: Icons.search_rounded,
|
WorkspaceSection.audio,
|
||||||
label: '全局搜索',
|
WorkspaceSection.documents,
|
||||||
onTap: onSearch,
|
WorkspaceSection.shares,
|
||||||
),
|
WorkspaceSection.recycle,
|
||||||
_MobileMenuRow(
|
])
|
||||||
icon: Icons.folder_rounded,
|
_MobileMenuRow(
|
||||||
label: '文件管理',
|
icon: _mobileSectionIcon(section),
|
||||||
onTap: () => onSection(WorkspaceSection.files),
|
label: section.label,
|
||||||
),
|
onTap: () => onSection(section),
|
||||||
_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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
_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(),
|
const ShadSeparator.horizontal(),
|
||||||
_MobileMenuRow(
|
_MobileMenuRow(
|
||||||
|
|||||||
Reference in New Issue
Block a user