feat: support custom GitHub mirror config (#1191)

This commit is contained in:
Levi authored and GitHub committed 2025-11-29 12:39:24 +08:00
1 parent 14d9642d3c
commit 4ccbc3ebc5
7 files changed
+449 -53

No files matched your search

@@ -86,6 +86,7 @@ class ExtraConfig {
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
ExtraConfig({
this.themeMode = '',
@@ -109,7 +110,8 @@ class DownloadCategory {
String path;
bool isBuiltIn;
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
bool isDeleted; // Mark built-in categories as deleted instead of removing them
bool
isDeleted; // Mark built-in categories as deleted instead of removing them
DownloadCategory({
required this.name,
@@ -181,3 +183,46 @@ class ExtraConfigBt {
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
}
enum GithubMirrorType {
jsdelivr,
ghProxy,
}
@JsonSerializable()
class GithubMirror {
GithubMirrorType type;
String url;
bool isBuiltIn;
bool isDeleted;
GithubMirror({
required this.type,
required this.url,
this.isBuiltIn = false,
this.isDeleted = false,
});
factory GithubMirror.fromJson(Map<String, dynamic> json) =>
_$GithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$GithubMirrorToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ExtraConfigGithubMirror {
bool enabled;
List<GithubMirror> mirrors;
ExtraConfigGithubMirror({
this.enabled = true,
this.mirrors = const [],
});
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
json == null
? ExtraConfigGithubMirror()
: _$ExtraConfigGithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
}
@@ -15,7 +15,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
<String, dynamic>{
@@ -81,7 +82,10 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
)..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
)
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
..githubMirror = ExtraConfigGithubMirror.fromJson(
json['githubMirror'] as Map<String, dynamic>?);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
<String, dynamic>{
@@ -94,6 +98,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(),
'githubMirror': instance.githubMirror.toJson(),
};
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>
@@ -126,10 +131,9 @@ Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>
WebhookConfig(
enable: json['enable'] as bool? ?? false,
urls: (json['urls'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
urls:
(json['urls'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
);
Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
@@ -191,3 +195,40 @@ Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
val['customTrackers'] = instance.customTrackers;
return val;
}
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
url: json['url'] as String,
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) =>
<String, dynamic>{
'type': _$GithubMirrorTypeEnumMap[instance.type]!,
'url': instance.url,
'isBuiltIn': instance.isBuiltIn,
'isDeleted': instance.isDeleted,
};
const _$GithubMirrorTypeEnumMap = {
GithubMirrorType.jsdelivr: 'jsdelivr',
GithubMirrorType.ghProxy: 'ghProxy',
};
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(
Map<String, dynamic> json) =>
ExtraConfigGithubMirror(
enabled: json['enabled'] as bool? ?? true,
mirrors: (json['mirrors'] as List<dynamic>?)
?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(
ExtraConfigGithubMirror instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
};
@@ -526,17 +526,22 @@ class AppController extends GetxController with WindowListener, TrayListener {
// Initialize default download categories if empty
if (extra.downloadCategories.isEmpty) {
await _initDefaultDownloadCategories();
_initDefaultDownloadCategories();
}
// Initialize default GitHub mirrors if empty
if (extra.githubMirror.mirrors.isEmpty) {
_initDefaultGithubMirrors();
}
}
Future<void> _initDefaultDownloadCategories() async {
void _initDefaultDownloadCategories() {
final extra = downloaderConfig.value.extra;
final downloadDir = downloaderConfig.value.downloadDir;
// Add default built-in categories with i18n keys
// No need to set initial name value, it will be retrieved via nameKey
extra.downloadCategories.addAll([
extra.downloadCategories = [
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Music'),
@@ -561,7 +566,25 @@ class AppController extends GetxController with WindowListener, TrayListener {
isBuiltIn: true,
nameKey: 'categoryProgram',
),
]);
];
}
void _initDefaultGithubMirrors() {
final extra = downloaderConfig.value.extra;
// Add default built-in GitHub mirrors
extra.githubMirror.mirrors = [
GithubMirror(
type: GithubMirrorType.jsdelivr,
url: 'https://fastly.jsdelivr.net/gh',
isBuiltIn: true,
),
GithubMirror(
type: GithubMirrorType.ghProxy,
url: 'https://fastgit.cc',
isBuiltIn: true,
),
];
}
Future<void> _initLaunchAtStartup() async {
@@ -884,6 +884,136 @@ class SettingView extends GetView<SettingController> {
},
);
// advanced config GitHub mirror items start
final buildGithubMirror = _buildConfigItem(
'githubMirror',
() => downloaderCfg.value.extra.githubMirror.enabled ? 'on'.tr : 'off'.tr,
(Key key) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'githubMirrorEnable'.tr,
style: Theme.of(Get.context!).textTheme.bodyMedium,
),
const Spacer(),
Switch(
value: downloaderCfg.value.extra.githubMirror.enabled,
onChanged: (value) {
downloaderCfg.update((val) {
val!.extra.githubMirror.enabled = value;
});
debounceSave();
},
),
],
),
_padding,
Text(
'githubMirrorDesc'.tr,
style: Theme.of(Get.context!).textTheme.bodySmall,
),
_padding,
// List of existing GitHub mirrors
...downloaderCfg.value.extra.githubMirror.mirrors
.where((m) => !m.isDeleted)
.toList()
.asMap()
.entries
.map((entry) {
// Get the original index in the full list
final mirror = entry.value;
final index = downloaderCfg.value.extra.githubMirror.mirrors
.indexOf(mirror);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: Text(
mirror.url,
style:
Theme.of(Get.context!).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'edit'.tr,
onPressed: () {
_showGithubMirrorDialog(
index: index, initialMirror: mirror);
},
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
tooltip: 'delete'.tr,
onPressed: () async {
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('tip'.tr),
content: Text('confirmDelete'.tr),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: Text('confirm'.tr),
),
],
),
);
if (confirmed != true) return;
if (mirror.isBuiltIn) {
// Mark built-in mirror as deleted (logical delete)
downloaderCfg.update((val) {
mirror.isDeleted = true;
});
} else {
// Remove custom mirrors completely (physical delete)
final mirrors = List<GithubMirror>.from(
downloaderCfg.value.extra.githubMirror.mirrors);
mirrors.removeAt(index);
downloaderCfg.update((val) {
val!.extra.githubMirror.mirrors = mirrors;
});
}
await debounceSave();
},
),
],
),
);
}),
_padding,
// Add button
OutlinedButton.icon(
onPressed: () {
_showGithubMirrorDialog();
},
icon: const Icon(Icons.add),
label: Text('add'.tr),
),
],
);
},
);
// advanced config API items start
final buildApiProtocol = _buildConfigItem(
'protocol',
@@ -1092,7 +1222,8 @@ class SettingView extends GetView<SettingController> {
tooltip: 'delete'.tr,
onPressed: () async {
// Create new list to avoid unmodifiable list error
final urls = List<String>.from(downloaderCfg.value.webhook.urls);
final urls =
List<String>.from(downloaderCfg.value.webhook.urls);
urls.removeAt(index);
downloaderCfg.update((val) {
val!.webhook.urls = urls;
@@ -1247,7 +1378,10 @@ class SettingView extends GetView<SettingController> {
Text('network'.tr),
Card(
child: Column(
children: _addDivider([buildProxy()]),
children: _addDivider([
buildProxy(),
buildGithubMirror(),
]),
)),
const Text('API'),
Card(
@@ -1307,12 +1441,12 @@ class SettingView extends GetView<SettingController> {
final url = value.trim().toLowerCase();
if (!url.startsWith('http://') &&
!url.startsWith('https://')) {
return 'webhookUrlInvalid'.tr;
return 'urlInvalid'.tr;
}
try {
Uri.parse(value.trim());
} catch (e) {
return 'webhookUrlInvalid'.tr;
return 'urlInvalid'.tr;
}
return null;
},
@@ -1366,7 +1500,8 @@ class SettingView extends GetView<SettingController> {
saveController.start();
try {
// Create new list to avoid unmodifiable list error
final urls = List<String>.from(downloaderCfg.value.webhook.urls);
final urls =
List<String>.from(downloaderCfg.value.webhook.urls);
if (isEdit) {
urls[index] = url;
} else {
@@ -1394,6 +1529,125 @@ class SettingView extends GetView<SettingController> {
);
}
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
final isEdit = index != null;
GithubMirrorType selectedType =
initialMirror?.type ?? GithubMirrorType.jsdelivr;
final urlController = TextEditingController(text: initialMirror?.url ?? '');
final saveController = TextButtonLoadingController();
final appController = Get.find<AppController>();
final downloaderCfg = appController.downloaderConfig;
final formKey = GlobalKey<FormState>();
showDialog(
context: Get.context!,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<GithubMirrorType>(
value: selectedType,
decoration: InputDecoration(
labelText: 'githubMirrorType'.tr,
),
onChanged: (value) {
if (value != null) {
setState(() {
selectedType = value;
});
}
},
items: GithubMirrorType.values
.map((type) => DropdownMenuItem(
value: type,
child: Text(type.name),
))
.toList(),
),
const SizedBox(height: 16),
TextFormField(
controller: urlController,
decoration: InputDecoration(
labelText: 'githubMirrorUrl'.tr,
hintText: 'githubMirrorUrlHint'.tr,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'required'.tr;
}
final url = value.trim().toLowerCase();
if (!url.startsWith('http://') &&
!url.startsWith('https://')) {
return 'urlInvalid'.tr;
}
try {
Uri.parse(value.trim());
} catch (e) {
return 'urlInvalid'.tr;
}
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel'.tr),
),
TextButtonLoading(
controller: saveController,
onPressed: () async {
if (!formKey.currentState!.validate()) {
return;
}
final url = urlController.text.trim();
if (url.isEmpty) return;
saveController.start();
try {
// Create new list to avoid unmodifiable list error
final mirrors = List<GithubMirror>.from(
downloaderCfg.value.extra.githubMirror.mirrors);
final newMirror = GithubMirror(
type: selectedType,
url: url,
);
if (isEdit) {
mirrors[index] = newMirror;
} else {
mirrors.add(newMirror);
}
downloaderCfg.update((val) {
val!.extra.githubMirror.mirrors = mirrors;
});
await appController.saveConfig();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
showErrorMessage(e);
} finally {
saveController.stop();
}
},
child: Text('confirm'.tr),
),
],
),
),
);
}
void _tapInputWidget(GlobalKey key) {
if (key.currentContext == null) {
return;
+7 -1
View File
@@ -73,7 +73,7 @@ const enUS = {
'webhookTest': 'Test',
'webhookTestSuccess': 'Webhook test successful',
'webhookTestFail': 'Webhook test failed',
'webhookUrlInvalid': 'Please enter a valid HTTP or HTTPS URL',
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
'required': 'This field is required',
'show': 'Show',
'continue': 'Continue',
@@ -153,5 +153,11 @@ const enUS = {
'categoryPath': 'Category Path',
'builtInCategory': 'Built-in category cannot be deleted',
'selectCategory': 'Select Category',
'githubMirror': 'GitHub Mirror',
'githubMirrorEnable': 'Enable GitHub Mirror',
'githubMirrorDesc': 'Use mirrors to accelerate GitHub content downloads',
'githubMirrorType': 'Mirror Type',
'githubMirrorUrl': 'Mirror URL',
'githubMirrorUrlHint': 'Enter mirror URL',
},
};
+7 -1
View File
@@ -72,7 +72,7 @@ const zhCN = {
'webhookTest': '测试',
'webhookTestSuccess': 'Webhook 测试成功',
'webhookTestFail': 'Webhook 测试失败',
'webhookUrlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
'required': '此项为必填项',
'show': '显示',
'continue': '继续',
@@ -150,5 +150,11 @@ const zhCN = {
'categoryPath': '分类路径',
'builtInCategory': '内置分类不能删除',
'selectCategory': '选择分类',
'githubMirror': 'GitHub 镜像',
'githubMirrorEnable': '启用 GitHub 镜像',
'githubMirrorDesc': '使用镜像站点加速 GitHub 内容下载',
'githubMirrorType': '镜像类型',
'githubMirrorUrl': '镜像地址',
'githubMirrorUrlHint': '请输入镜像地址',
}
};
+56 -35
View File
@@ -1,39 +1,41 @@
import 'package:dio/dio.dart';
import 'package:get/get.dart';
// List of mirrors for GitHub
const _sourceMirror = [
// github.tbedu.top: https://github.tbedu.top/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://github.tbedu.top/", r"(.*)"],
// fastgit.cc: https://fastgit.cc/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://fastgit.cc/", r"(.*)"],
// gitproxy.click: https://gitproxy.click/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://gitproxy.click/", r"(.*)"],
// jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
[
"https://fastly.jsdelivr.net/gh",
r".*raw.githubusercontent.com(/.*)(/.*)/(?:master|main)(/.*)"
],
];
// List of mirrors for GitHub release
const _assertMirror = [
// github.tbedu.top: https://https://github.tbedu.top/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://github.tbedu.top/", r"(.*)"],
// fastgit.cc: https://fastgit.cc/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://fastgit.cc/", r"(.*)"],
// gitproxy.click: https://gitproxy.click/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://gitproxy.click/", r"(.*)"],
];
import '../api/model/downloader_config.dart';
import '../app/modules/app/controllers/app_controller.dart';
enum MirrorType {
githubSource,
githubRelease,
}
/// Get the configured mirrors
List<GithubMirror> _getConfiguredMirrors() {
try {
final appController = Get.find<AppController>();
final config = appController.downloaderConfig.value.extra.githubMirror;
if (!config.enabled) {
return [];
}
// Return configured mirrors (filtering not deleted ones)
return config.mirrors.where((m) => !m.isDeleted).toList();
} catch (e) {
// Fallback to empty list if controller not found
return [];
}
}
/// Auto detect the best mirror for the given [rawUrl] and [type]
Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
final mirrorUrls = githubMirrorUrls(rawUrl, type);
// If no mirrors, return original URL
if (mirrorUrls.isEmpty) {
return rawUrl;
}
// Ping all mirrors and get the fastest one
final pingResult = await Future.wait(mirrorUrls.map((e) async {
final client = Dio()
@@ -64,20 +66,39 @@ Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
}
List<String> githubMirrorUrls(String rawUrl, MirrorType type) {
final mirrors = switch (type) {
MirrorType.githubSource => _sourceMirror,
MirrorType.githubRelease => _assertMirror,
};
final mirrors = _getConfiguredMirrors();
final ret = <String>[];
for (final cdn in mirrors) {
final reg = RegExp(cdn[1]);
final match = reg.firstMatch(rawUrl.toString());
var matchStr = "";
for (var i = 1; i <= match!.groupCount; i++) {
matchStr += match.group(i)!;
for (final mirror in mirrors) {
String? mirrorUrl;
if (mirror.type == GithubMirrorType.jsdelivr) {
// jsdelivr only supports source files
if (type != MirrorType.githubSource) {
continue;
}
// Transform: https://raw.githubusercontent.com/user/repo/master/path
// To: https://fastly.jsdelivr.net/gh/user/repo/path
final jsDelivrPattern = RegExp(
r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)');
final match = jsDelivrPattern.firstMatch(rawUrl);
if (match != null) {
final user = match.group(1);
final repo = match.group(2);
final path = match.group(3);
mirrorUrl = '${mirror.url}$user$repo$path';
}
} else if (mirror.type == GithubMirrorType.ghProxy) {
// gh-proxy supports both source and release
// Simply prepend the mirror URL to the original URL
mirrorUrl = '${mirror.url}/$rawUrl';
}
if (mirrorUrl != null) {
ret.add(mirrorUrl);
}
ret.add("${cdn[0]}$matchStr");
}
return ret;
}