feat: persist media scan task logs
This commit is contained in:
@@ -28,6 +28,7 @@ class StorageKeys {
|
||||
static const String fileCacheTTLMinutes = 'guangya.fileCacheTTLMinutes';
|
||||
static const String defaultFilePageSize = 'guangya.defaultFilePageSize';
|
||||
static const String fastTransferSession = 'guangya.fastTransferSession';
|
||||
static const String mediaScanHistory = 'guangya.mediaScanHistory';
|
||||
}
|
||||
|
||||
class StorageManager {
|
||||
|
||||
@@ -455,6 +455,32 @@ class MediaLibraryScanProgress {
|
||||
});
|
||||
}
|
||||
|
||||
class MediaLibraryScanLog {
|
||||
final DateTime createdAt;
|
||||
final String message;
|
||||
final bool isError;
|
||||
|
||||
const MediaLibraryScanLog({
|
||||
required this.createdAt,
|
||||
required this.message,
|
||||
this.isError = false,
|
||||
});
|
||||
|
||||
factory MediaLibraryScanLog.fromJson(Map<String, dynamic> json) {
|
||||
return MediaLibraryScanLog(
|
||||
createdAt: _parseDate(json['createdAt']) ?? DateTime.now(),
|
||||
message: json['message']?.toString() ?? '',
|
||||
isError: json['isError'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'message': message,
|
||||
'isError': isError,
|
||||
};
|
||||
}
|
||||
|
||||
class ParsedMediaName {
|
||||
final String title;
|
||||
final int? year;
|
||||
|
||||
@@ -554,7 +554,16 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
Expanded(child: wallContent),
|
||||
],
|
||||
);
|
||||
if (!state.isScanning) return content;
|
||||
if (!state.isScanning) {
|
||||
if (state.scanLogs.isEmpty) return content;
|
||||
return Column(
|
||||
children: [
|
||||
_recentScanLogs(context, state),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(child: content),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_scanProgress(context, state),
|
||||
@@ -607,32 +616,130 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.border),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 120, child: ShadProgress()),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.progress.phase,
|
||||
style: TextStyle(color: cs.foreground),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 120, child: ShadProgress()),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.progress.phase,
|
||||
style: TextStyle(color: cs.foreground),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'已发现 ${state.progress.completed} 个视频文件,已入库资源会实时显示。',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'已发现 ${state.progress.completed} 个视频文件,已入库资源会实时显示。',
|
||||
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.scanLogs.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Divider(height: 1, color: cs.border),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'任务日志',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 84,
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: state.scanLogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = state.scanLogs.reversed.elementAt(index);
|
||||
final time = TimeOfDay.fromDateTime(
|
||||
log.createdAt,
|
||||
).format(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
'$time ${log.message}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: log.isError
|
||||
? cs.destructive
|
||||
: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _recentScanLogs(BuildContext context, MediaLibraryState state) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.muted.withValues(alpha: 0.68),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'最近扫描日志',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 54,
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: state.scanLogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = state.scanLogs.reversed.elementAt(index);
|
||||
final time = TimeOfDay.fromDateTime(
|
||||
log.createdAt,
|
||||
).format(context);
|
||||
return Text(
|
||||
'$time ${log.message}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: log.isError ? cs.destructive : cs.mutedForeground,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mainEmpty(BuildContext context, String title, String subtitle) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Center(
|
||||
|
||||
@@ -16,6 +16,7 @@ class MediaLibraryState {
|
||||
final bool isLoading;
|
||||
final bool isScanning;
|
||||
final MediaLibraryScanProgress progress;
|
||||
final List<MediaLibraryScanLog> scanLogs;
|
||||
final String searchQuery;
|
||||
final String? errorMessage;
|
||||
final String? statusMessage;
|
||||
@@ -27,6 +28,7 @@ class MediaLibraryState {
|
||||
this.isLoading = false,
|
||||
this.isScanning = false,
|
||||
this.progress = const MediaLibraryScanProgress(),
|
||||
this.scanLogs = const [],
|
||||
this.searchQuery = '',
|
||||
this.errorMessage,
|
||||
this.statusMessage,
|
||||
@@ -60,6 +62,7 @@ class MediaLibraryState {
|
||||
bool? isLoading,
|
||||
bool? isScanning,
|
||||
MediaLibraryScanProgress? progress,
|
||||
List<MediaLibraryScanLog>? scanLogs,
|
||||
String? searchQuery,
|
||||
String? errorMessage,
|
||||
bool clearError = false,
|
||||
@@ -75,6 +78,7 @@ class MediaLibraryState {
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
progress: progress ?? this.progress,
|
||||
scanLogs: scanLogs ?? this.scanLogs,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
|
||||
@@ -104,10 +108,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
final items = selectedID == null
|
||||
? <MediaLibraryItem>[]
|
||||
: await _loadItems(selectedID);
|
||||
final logs = _loadScanHistory();
|
||||
state = state.copyWith(
|
||||
libraries: libraries,
|
||||
selectedLibraryID: selectedID,
|
||||
items: items,
|
||||
scanLogs: logs,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(errorMessage: e.toString());
|
||||
@@ -268,6 +274,12 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
progress: const MediaLibraryScanProgress(phase: '准备扫描'),
|
||||
scanLogs: [
|
||||
MediaLibraryScanLog(
|
||||
createdAt: DateTime.now(),
|
||||
message: '任务已创建,开始扫描「${library.name}」',
|
||||
),
|
||||
],
|
||||
clearError: true,
|
||||
clearStatus: true,
|
||||
);
|
||||
@@ -322,6 +334,11 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
completed: completed,
|
||||
),
|
||||
);
|
||||
_appendScanLog(
|
||||
item.tmdbID == null
|
||||
? '已入库:${file.name}(未匹配 TMDB)'
|
||||
: '已识别并入库:${file.name} → ${item.title}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +356,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
|
||||
for (final source in library.sources) {
|
||||
if (_cancelScan) break;
|
||||
_appendScanLog('扫描目录:${source.path}');
|
||||
state = state.copyWith(
|
||||
progress: MediaLibraryScanProgress(
|
||||
phase: '扫描 ${source.path}',
|
||||
@@ -374,13 +392,20 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
? '扫描已停止,已保留 ${items.length} 个项目'
|
||||
: '扫描完成:${items.length} 个视频文件',
|
||||
);
|
||||
_appendScanLog(
|
||||
_cancelScan
|
||||
? '扫描已停止,已保留 ${items.length} 个条目'
|
||||
: '扫描完成,共入库 ${items.length} 个条目',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(errorMessage: e.toString());
|
||||
_appendScanLog('扫描失败:$e', isError: true);
|
||||
} finally {
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
progress: const MediaLibraryScanProgress(),
|
||||
);
|
||||
unawaited(_persistScanHistory());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,8 +414,41 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
||||
state = state.copyWith(
|
||||
progress: const MediaLibraryScanProgress(phase: '正在停止扫描'),
|
||||
);
|
||||
_appendScanLog('正在请求停止扫描…');
|
||||
}
|
||||
|
||||
void _appendScanLog(String message, {bool isError = false}) {
|
||||
final logs = [
|
||||
...state.scanLogs,
|
||||
MediaLibraryScanLog(
|
||||
createdAt: DateTime.now(),
|
||||
message: message,
|
||||
isError: isError,
|
||||
),
|
||||
];
|
||||
if (logs.length > 120) logs.removeRange(0, logs.length - 120);
|
||||
state = state.copyWith(scanLogs: logs);
|
||||
}
|
||||
|
||||
List<MediaLibraryScanLog> _loadScanHistory() {
|
||||
final raw = StorageManager.get<dynamic>(StorageKeys.mediaScanHistory);
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(entry) =>
|
||||
MediaLibraryScanLog.fromJson(Map<String, dynamic>.from(entry)),
|
||||
)
|
||||
.where((entry) => entry.message.isNotEmpty)
|
||||
.take(120)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _persistScanHistory() => StorageManager.set(
|
||||
StorageKeys.mediaScanHistory,
|
||||
state.scanLogs.map((entry) => entry.toJson()).toList(),
|
||||
);
|
||||
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user