feat: persist media scan task logs

This commit is contained in:
ngfchl
2026-07-18 16:15:58 +08:00
parent b0a2970ef7
commit ff13645f6f
4 changed files with 210 additions and 18 deletions
+1
View File
@@ -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 {
+26
View File
@@ -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;
+125 -18
View File
@@ -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(
+58
View File
@@ -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);
}