feat: support post-task script hooks (#1265)

This commit is contained in:
Copilot authored and GitHub committed 2026-02-08 18:40:06 +08:00
1 parent 3eb6df55df
commit 1b1631b41a
20 files changed
+911

No files matched your search

@@ -10,6 +10,7 @@ class DownloaderConfig {
ExtraConfig extra = ExtraConfig();
ProxyConfig proxy = ProxyConfig();
WebhookConfig webhook = WebhookConfig();
ScriptConfig script = ScriptConfig();
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
ArchiveConfig archive = ArchiveConfig();
bool autoDeleteMissingFileTasks;
@@ -149,6 +150,22 @@ class WebhookConfig {
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
}
@JsonSerializable()
class ScriptConfig {
bool enable;
List<String> paths;
ScriptConfig({
this.enable = false,
this.paths = const [],
});
factory ScriptConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ScriptConfig() : _$ScriptConfigFromJson(json);
Map<String, dynamic> toJson() => _$ScriptConfigToJson(this);
}
@JsonSerializable()
class ProxyConfig {
bool enable;
@@ -19,6 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script =
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson(
json['autoTorrent'] as Map<String, dynamic>?)
..archive =
@@ -32,6 +34,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
'extra': instance.extra.toJson(),
'proxy': instance.proxy.toJson(),
'webhook': instance.webhook.toJson(),
'script': instance.script.toJson(),
'autoTorrent': instance.autoTorrent.toJson(),
'archive': instance.archive.toJson(),
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
@@ -153,6 +156,20 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
'urls': instance.urls,
};
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
ScriptConfig(
enable: json['enable'] as bool? ?? false,
paths:
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
);
Map<String, dynamic> _$ScriptConfigToJson(ScriptConfig instance) =>
<String, dynamic>{
'enable': instance.enable,
'paths': instance.paths,
};
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
enable: json['enable'] as bool? ?? false,
system: json['system'] as bool? ?? false,
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:badges/badges.dart' as badges;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
@@ -1432,6 +1433,92 @@ class SettingView extends GetView<SettingController> {
},
);
// advanced config script items
final buildScript = _buildConfigItem(
'script',
() => downloaderCfg.value.script.enable ? 'on'.tr : 'off'.tr,
(Key key) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'scriptEnable'.tr,
style: Theme.of(Get.context!).textTheme.bodyMedium,
),
const Spacer(),
Switch(
value: downloaderCfg.value.script.enable,
onChanged: (value) {
downloaderCfg.update((val) {
val!.script.enable = value;
});
debounceSave();
},
),
],
),
_padding,
Text(
'scriptDesc'.tr,
style: Theme.of(Get.context!).textTheme.bodySmall,
),
_padding,
// List of existing script paths
...downloaderCfg.value.script.paths.asMap().entries.map((entry) {
final index = entry.key;
final path = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
child: Text(
path,
style: Theme.of(Get.context!).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'edit'.tr,
onPressed: () {
_showScriptDialog(index: index, initialPath: path);
},
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
tooltip: 'delete'.tr,
onPressed: () async {
// Create new list to avoid unmodifiable list error
final paths =
List<String>.from(downloaderCfg.value.script.paths);
paths.removeAt(index);
downloaderCfg.update((val) {
val!.script.paths = paths;
});
await debounceSave();
},
),
],
),
);
}),
_padding,
// Add button
OutlinedButton.icon(
onPressed: () {
_showScriptDialog();
},
icon: const Icon(Icons.add),
label: Text('add'.tr),
),
],
);
},
);
// advanced config log items start
buildLogsDir() {
return ListTile(
@@ -1603,6 +1690,7 @@ class SettingView extends GetView<SettingController> {
child: Column(
children: _addDivider([
buildWebhook(),
if (Util.isDesktop()) buildScript(),
buildLogsDir(),
]),
)),
@@ -1734,6 +1822,104 @@ class SettingView extends GetView<SettingController> {
);
}
void _showScriptDialog({int? index, String? initialPath}) {
final pathController = TextEditingController(text: initialPath ?? '');
final saveController = TextButtonLoadingController();
final appController = Get.find<AppController>();
final downloaderCfg = appController.downloaderConfig;
final isEdit = index != null;
final formKey = GlobalKey<FormState>();
showDialog(
context: Get.context!,
builder: (dialogContext) => AlertDialog(
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: pathController,
decoration: InputDecoration(
hintText: 'scriptPathHint'.tr,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'required'.tr;
}
return null;
},
),
),
if (Util.isDesktop())
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: IconButton(
icon: const Icon(Icons.folder_open),
onPressed: () async {
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
final filePath = result.files.first.path;
if (filePath != null) {
pathController.text = filePath;
}
}
},
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel'.tr),
),
TextButtonLoading(
controller: saveController,
onPressed: () async {
if (!formKey.currentState!.validate()) {
return;
}
final path = pathController.text.trim();
if (path.isEmpty) return;
saveController.start();
try {
// Create new list to avoid unmodifiable list error
final paths =
List<String>.from(downloaderCfg.value.script.paths);
if (isEdit) {
paths[index] = path;
} else {
paths.add(path);
}
downloaderCfg.update((val) {
val!.script.paths = paths;
});
await appController.saveConfig();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
showErrorMessage(e);
} finally {
saveController.stop();
}
},
child: Text('confirm'.tr),
),
],
),
);
}
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
final isEdit = index != null;
GithubMirrorType selectedType =
+4
View File
@@ -80,6 +80,10 @@ const enUS = {
'webhookTest': 'Test',
'webhookTestSuccess': 'Webhook test successful',
'webhookTestFail': 'Webhook test failed',
'script': 'Script',
'scriptEnable': 'Enable Script',
'scriptDesc': 'Execute custom scripts when downloads complete successfully',
'scriptPathHint': 'Enter script file path (e.g., /path/to/script.sh)',
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
'required': 'This field is required',
'show': 'Show',
+4
View File
@@ -78,6 +78,10 @@ const zhCN = {
'webhookTest': '测试',
'webhookTestSuccess': 'Webhook 测试成功',
'webhookTestFail': 'Webhook 测试失败',
'script': '脚本执行',
'scriptEnable': '启用脚本',
'scriptDesc': '在下载成功完成时执行自定义脚本',
'scriptPathHint': '请输入脚本文件路径(例如:/path/to/script.sh)',
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
'required': '此项为必填项',
'show': '显示',