diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 8b5bc34c..83c416de 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -86,6 +86,7 @@ class ExtraConfig { List 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 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 json) => + _$GithubMirrorFromJson(json); + + Map toJson() => _$GithubMirrorToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ExtraConfigGithubMirror { + bool enabled; + List mirrors; + + ExtraConfigGithubMirror({ + this.enabled = true, + this.mirrors = const [], + }); + + factory ExtraConfigGithubMirror.fromJson(Map? json) => + json == null + ? ExtraConfigGithubMirror() + : _$ExtraConfigGithubMirrorFromJson(json); + + Map toJson() => _$ExtraConfigGithubMirrorToJson(this); +} diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index a4ccb4fc..6eeabca8 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -15,7 +15,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map json) => json['protocolConfig'] as Map?) ..extra = ExtraConfig.fromJson(json['extra'] as Map?) ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) - ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?); + ..webhook = + WebhookConfig.fromJson(json['webhook'] as Map?); Map _$DownloaderConfigToJson(DownloaderConfig instance) => { @@ -81,7 +82,10 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( ?.map((e) => DownloadCategory.fromJson(e as Map)) .toList() ?? const [], - )..bt = ExtraConfigBt.fromJson(json['bt'] as Map); + ) + ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) + ..githubMirror = ExtraConfigGithubMirror.fromJson( + json['githubMirror'] as Map?); Map _$ExtraConfigToJson(ExtraConfig instance) => { @@ -94,6 +98,7 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => 'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(), 'bt': instance.bt.toJson(), + 'githubMirror': instance.githubMirror.toJson(), }; DownloadCategory _$DownloadCategoryFromJson(Map json) => @@ -126,10 +131,9 @@ Map _$DownloadCategoryToJson(DownloadCategory instance) { WebhookConfig _$WebhookConfigFromJson(Map json) => WebhookConfig( enable: json['enable'] as bool? ?? false, - urls: (json['urls'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], + urls: + (json['urls'] as List?)?.map((e) => e as String).toList() ?? + const [], ); Map _$WebhookConfigToJson(WebhookConfig instance) => @@ -191,3 +195,40 @@ Map _$ExtraConfigBtToJson(ExtraConfigBt instance) { val['customTrackers'] = instance.customTrackers; return val; } + +GithubMirror _$GithubMirrorFromJson(Map 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 _$GithubMirrorToJson(GithubMirror instance) => + { + 'type': _$GithubMirrorTypeEnumMap[instance.type]!, + 'url': instance.url, + 'isBuiltIn': instance.isBuiltIn, + 'isDeleted': instance.isDeleted, + }; + +const _$GithubMirrorTypeEnumMap = { + GithubMirrorType.jsdelivr: 'jsdelivr', + GithubMirrorType.ghProxy: 'ghProxy', +}; + +ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson( + Map json) => + ExtraConfigGithubMirror( + enabled: json['enabled'] as bool? ?? true, + mirrors: (json['mirrors'] as List?) + ?.map((e) => GithubMirror.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ExtraConfigGithubMirrorToJson( + ExtraConfigGithubMirror instance) => + { + 'enabled': instance.enabled, + 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), + }; diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index 5670ef5e..96a3e188 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -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 _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 _initLaunchAtStartup() async { diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index f50e798b..ecef615e 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -884,6 +884,136 @@ class SettingView extends GetView { }, ); + // 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( + 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.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 { tooltip: 'delete'.tr, onPressed: () async { // Create new list to avoid unmodifiable list error - final urls = List.from(downloaderCfg.value.webhook.urls); + final urls = + List.from(downloaderCfg.value.webhook.urls); urls.removeAt(index); downloaderCfg.update((val) { val!.webhook.urls = urls; @@ -1247,7 +1378,10 @@ class SettingView extends GetView { 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 { 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 { saveController.start(); try { // Create new list to avoid unmodifiable list error - final urls = List.from(downloaderCfg.value.webhook.urls); + final urls = + List.from(downloaderCfg.value.webhook.urls); if (isEdit) { urls[index] = url; } else { @@ -1394,6 +1529,125 @@ class SettingView extends GetView { ); } + 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(); + final downloaderCfg = appController.downloaderConfig; + final formKey = GlobalKey(); + + 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( + 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.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; diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 1fb99012..b2079de1 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -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', }, }; diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index e778add9..cd508425 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -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': '请输入镜像地址', } }; diff --git a/ui/flutter/lib/util/github_mirror.dart b/ui/flutter/lib/util/github_mirror.dart index ceb64c2f..63fdccb6 100644 --- a/ui/flutter/lib/util/github_mirror.dart +++ b/ui/flutter/lib/util/github_mirror.dart @@ -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 _getConfiguredMirrors() { + try { + final appController = Get.find(); + 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 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 githubAutoMirror(String rawUrl, MirrorType type) async { } List githubMirrorUrls(String rawUrl, MirrorType type) { - final mirrors = switch (type) { - MirrorType.githubSource => _sourceMirror, - MirrorType.githubRelease => _assertMirror, - }; + final mirrors = _getConfiguredMirrors(); final ret = []; - 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; }