diff --git a/lib/src/l18n/en_US.dart b/lib/src/l18n/en_US.dart index 88ad96f47..4c20d1490 100644 --- a/lib/src/l18n/en_US.dart +++ b/lib/src/l18n/en_US.dart @@ -718,6 +718,32 @@ class en_US { 'brokenDownloadPathHint': 'Seems your download path is broken, download function may be ineffective', 'brokenExtraScanPathHint': 'Seems your default local gallery path is broken, local gallery may be not recognized', 'useJH2UpdateGallery': 'Use JH server to accelerate gallery updates', + 'enableAria2Push': 'Enable Push Archive to aria2', + 'enableAria2PushHint': 'Push archive link to aria2 instead of downloading in app', + 'aria2Settings': 'aria2 Settings', + 'aria2RpcUrl': 'RPC URL', + 'aria2RpcUrlHint': 'http://127.0.0.1:6800/jsonrpc', + 'aria2Secret': 'RPC Secret', + 'aria2DownloadDir': 'aria2 Download Directory', + 'aria2DownloadDirHint': 'Optional, use aria2 default when empty', + 'aria2FilenameTemplate': 'Filename Template', + 'aria2FilenameTemplateHint': 'ArchiveV2 - {gid} - {title}.zip', + 'aria2FilenameTemplateHelp': 'Filename template help', + 'aria2FilenameTemplateOptionGid': '{gid}: Gallery ID', + 'aria2FilenameTemplateOptionTitle': '{title}: Title', + 'aria2FilenameTemplateOptionUploader': '{uploader}: Uploader', + 'aria2FilenameTemplateOptionCategory': '{category}: Category', + 'aria2FilenameTemplateExample': 'Example: ArchiveV2 - {gid} - {title}.zip', + 'aria2DefaultPushSelected': 'Default Select Push to aria2', + 'aria2DefaultPushSelectedHint': 'Pre-select "Push Archive to aria2" in archive dialog', + 'aria2ConnectTimeout': 'RPC Timeout', + 'testConnection': 'Test Connection', + 'aria2ConnectionSucceeded': 'aria2 connection succeeded', + 'aria2ConnectionFailed': 'aria2 connection failed', + 'pushArchiveToAria2': 'Push Archive to aria2', + 'aria2PushSuccess': 'Pushed to aria2: %s', + 'aria2PushFailed': 'Push to aria2 failed', + 'aria2NotConfiguredHint': 'Please enable and configure aria2 in Download Settings', /// archive bot settings 'archiveBotSettings': 'Archive Bot Settings', diff --git a/lib/src/l18n/zh_CN.dart b/lib/src/l18n/zh_CN.dart index aff6c8b4b..7f863699f 100644 --- a/lib/src/l18n/zh_CN.dart +++ b/lib/src/l18n/zh_CN.dart @@ -723,6 +723,32 @@ favnote:匹配收藏备注 'brokenDownloadPathHint': '似乎你的下载路径已经损坏,下载功能可能失效', 'brokenExtraScanPathHint': '似乎你的默认本地画廊路径已经损坏,本地画廊可能无法被识别', 'useJH2UpdateGallery': '使用JH服务器加速画廊更新', + 'enableAria2Push': '启用推送归档至aria2', + 'enableAria2PushHint': '将归档下载链接推送到aria2,不在应用内下载', + 'aria2Settings': 'aria2设置', + 'aria2RpcUrl': 'RPC地址', + 'aria2RpcUrlHint': 'http://127.0.0.1:6800/jsonrpc', + 'aria2Secret': 'RPC密钥', + 'aria2DownloadDir': 'aria2下载目录', + 'aria2DownloadDirHint': '可选,留空则使用aria2默认目录', + 'aria2FilenameTemplate': '文件名模板', + 'aria2FilenameTemplateHint': 'ArchiveV2 - {gid} - {title}.zip', + 'aria2FilenameTemplateHelp': '文件名模板说明', + 'aria2FilenameTemplateOptionGid': '{gid}:画廊ID', + 'aria2FilenameTemplateOptionTitle': '{title}:标题', + 'aria2FilenameTemplateOptionUploader': '{uploader}:上传者', + 'aria2FilenameTemplateOptionCategory': '{category}:分类', + 'aria2FilenameTemplateExample': '示例:ArchiveV2 - {gid} - {title}.zip', + 'aria2DefaultPushSelected': '默认选中推送至aria2', + 'aria2DefaultPushSelectedHint': '在归档弹窗中默认勾选“推送归档至aria2”', + 'aria2ConnectTimeout': 'RPC超时', + 'testConnection': '测试连接', + 'aria2ConnectionSucceeded': 'aria2连接成功', + 'aria2ConnectionFailed': 'aria2连接失败', + 'pushArchiveToAria2': '推送归档至aria2', + 'aria2PushSuccess': '已推送到aria2:%s', + 'aria2PushFailed': '推送到aria2失败', + 'aria2NotConfiguredHint': '请先在下载设置中启用并配置aria2', /// archive bot settings 'archiveBotSettings': '归档机器人设置', diff --git a/lib/src/pages/details/details_page_logic.dart b/lib/src/pages/details/details_page_logic.dart index 0bf27a7b7..d6eab2493 100644 --- a/lib/src/pages/details/details_page_logic.dart +++ b/lib/src/pages/details/details_page_logic.dart @@ -639,7 +639,7 @@ class DetailsPageLogic extends GetxController with LoginRequiredMixin, Scroll2To return; } - ({bool useBot, bool isOriginal, int size, String group})? result = await Get.dialog( + ({bool useBot, bool isOriginal, int size, String group, bool pushToAria2})? result = await Get.dialog( EHArchiveDialog( title: 'chooseArchive'.tr, archivePageUrl: state.galleryDetails!.archivePageUrl, @@ -651,27 +651,11 @@ class DetailsPageLogic extends GetxController with LoginRequiredMixin, Scroll2To return; } - ArchiveDownloadedData archive = ArchiveDownloadedData( - gid: state.galleryDetails!.galleryUrl.gid, - token: state.galleryDetails!.galleryUrl.token, - title: mainTitleText, - category: state.galleryDetails!.category, - pageCount: state.galleryDetails!.pageCount, - galleryUrl: state.galleryDetails!.galleryUrl.url, - uploader: state.galleryDetails!.uploader, - size: result.size, - coverUrl: state.galleryDetails!.cover.url, - publishTime: state.galleryDetails!.publishTime, - archiveStatusCode: ArchiveStatus.unlocking.code, - archivePageUrl: state.galleryDetails!.archivePageUrl, - isOriginal: result.isOriginal, - insertTime: DateTime.now().toString(), - sortOrder: 0, - groupName: result.group, - tags: state.galleryDetails != null ? tagMap2TagString(state.galleryDetails!.tags) : tagMap2TagString(state.gallery!.tags), - tagRefreshTime: DateTime.now().toString(), - parseSource: result.useBot ? ArchiveParseSource.bot.code : ArchiveParseSource.official.code, - ); + ArchiveDownloadedData archive = _buildArchiveTaskFromDialogResult(result); + if (downloadSetting.enableAria2Push.value && result.pushToAria2) { + await archiveDownloadService.pushArchiveToAria2(archive); + return; + } archiveDownloadService.downloadArchive(archive); updateGlobalGalleryStatus(); @@ -722,6 +706,30 @@ class DetailsPageLogic extends GetxController with LoginRequiredMixin, Scroll2To } } + ArchiveDownloadedData _buildArchiveTaskFromDialogResult(({bool useBot, bool isOriginal, int size, String group, bool pushToAria2}) result) { + return ArchiveDownloadedData( + gid: state.galleryDetails!.galleryUrl.gid, + token: state.galleryDetails!.galleryUrl.token, + title: mainTitleText, + category: state.galleryDetails!.category, + pageCount: state.galleryDetails!.pageCount, + galleryUrl: state.galleryDetails!.galleryUrl.url, + uploader: state.galleryDetails!.uploader, + size: result.size, + coverUrl: state.galleryDetails!.cover.url, + publishTime: state.galleryDetails!.publishTime, + archiveStatusCode: ArchiveStatus.unlocking.code, + archivePageUrl: state.galleryDetails!.archivePageUrl, + isOriginal: result.isOriginal, + insertTime: DateTime.now().toString(), + sortOrder: 0, + groupName: result.group, + tags: state.galleryDetails != null ? tagMap2TagString(state.galleryDetails!.tags) : tagMap2TagString(state.gallery!.tags), + tagRefreshTime: DateTime.now().toString(), + parseSource: result.useBot ? ArchiveParseSource.bot.code : ArchiveParseSource.official.code, + ); + } + Future handleTapHH() async { if (!userSetting.hasLoggedIn()) { showLoginToast(); diff --git a/lib/src/pages/download/mixin/archive/archive_download_page_logic_mixin.dart b/lib/src/pages/download/mixin/archive/archive_download_page_logic_mixin.dart index 2c77051cb..9e2035fbc 100644 --- a/lib/src/pages/download/mixin/archive/archive_download_page_logic_mixin.dart +++ b/lib/src/pages/download/mixin/archive/archive_download_page_logic_mixin.dart @@ -5,6 +5,7 @@ import 'package:jhentai/src/extension/get_logic_extension.dart'; import 'package:jhentai/src/mixin/scroll_to_top_logic_mixin.dart'; import 'package:jhentai/src/mixin/update_global_gallery_status_logic_mixin.dart'; import 'package:jhentai/src/setting/archive_bot_setting.dart'; +import 'package:jhentai/src/setting/download_setting.dart'; import 'package:jhentai/src/widget/eh_archive_parse_source_select_dialog.dart'; import '../../../../database/database.dart'; @@ -228,6 +229,14 @@ mixin ArchiveDownloadPageLogicMixin on GetxController changeParseSource(archive.gid, ArchiveParseSource.bot); }, ), + if (downloadSetting.enableAria2Push.value) + CupertinoActionSheetAction( + child: Text('pushArchiveToAria2'.tr), + onPressed: () { + backRoute(); + archiveDownloadService.pushArchiveToAria2(archive); + }, + ), CupertinoActionSheetAction( child: Text('changeGroup'.tr), onPressed: () { diff --git a/lib/src/pages/setting/download/aria2/aria2_settings_page.dart b/lib/src/pages/setting/download/aria2/aria2_settings_page.dart new file mode 100644 index 000000000..e6344a12a --- /dev/null +++ b/lib/src/pages/setting/download/aria2/aria2_settings_page.dart @@ -0,0 +1,232 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:jhentai/src/extension/widget_extension.dart'; +import 'package:jhentai/src/service/aria2_service.dart'; +import 'package:jhentai/src/setting/download_setting.dart'; +import 'package:jhentai/src/utils/snack_util.dart'; +import 'package:jhentai/src/utils/toast_util.dart'; +import 'package:jhentai/src/widget/loading_state_indicator.dart'; +import 'package:throttling/throttling.dart'; + +class Aria2SettingsPage extends StatefulWidget { + const Aria2SettingsPage({Key? key}) : super(key: key); + + @override + State createState() => _Aria2SettingsPageState(); +} + +class _Aria2SettingsPageState extends State { + late final TextEditingController rpcUrlController; + late final TextEditingController secretController; + late final TextEditingController dirController; + late final TextEditingController filenameTemplateController; + + bool enableAria2Push = downloadSetting.enableAria2Push.value; + int timeout = downloadSetting.aria2ConnectTimeout.value; + bool defaultPushSelected = downloadSetting.aria2DefaultPushSelected.value; + bool obscureSecret = true; + LoadingState testConnectionState = LoadingState.idle; + late final Debouncing saveDebouncing; + + @override + void initState() { + super.initState(); + rpcUrlController = TextEditingController(text: downloadSetting.aria2RpcUrl.value); + secretController = TextEditingController(text: downloadSetting.aria2Secret.value); + dirController = TextEditingController(text: downloadSetting.aria2DownloadDir.value); + filenameTemplateController = TextEditingController(text: downloadSetting.aria2FilenameTemplate.value); + saveDebouncing = Debouncing(duration: const Duration(milliseconds: 400)); + } + + @override + void dispose() { + rpcUrlController.dispose(); + secretController.dispose(); + dirController.dispose(); + filenameTemplateController.dispose(); + saveDebouncing.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text('aria2Settings'.tr), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text('enableAria2Push'.tr), + subtitle: Text('enableAria2PushHint'.tr), + value: enableAria2Push, + onChanged: (value) async { + setState(() => enableAria2Push = value); + await downloadSetting.saveEnableAria2Push(value); + }, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text('aria2DefaultPushSelected'.tr), + subtitle: Text('aria2DefaultPushSelectedHint'.tr), + value: enableAria2Push && defaultPushSelected, + onChanged: !enableAria2Push + ? null + : (value) async { + setState(() => defaultPushSelected = value); + await downloadSetting.saveAria2DefaultPushSelected(value); + }, + ), + TextField( + controller: rpcUrlController, + onChanged: (value) => saveDebouncing.debounce(() => downloadSetting.saveAria2RpcUrl(value.trim())), + decoration: InputDecoration( + labelText: 'aria2RpcUrl'.tr, + hintText: 'aria2RpcUrlHint'.tr, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: secretController, + obscureText: obscureSecret, + onChanged: (value) => saveDebouncing.debounce(() => downloadSetting.saveAria2Secret(value.trim())), + decoration: InputDecoration( + labelText: 'aria2Secret'.tr, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: () => setState(() => obscureSecret = !obscureSecret), + icon: Icon(obscureSecret ? Icons.visibility_off : Icons.visibility), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: dirController, + onChanged: (value) => saveDebouncing.debounce(() => downloadSetting.saveAria2DownloadDir(value.trim())), + decoration: InputDecoration( + labelText: 'aria2DownloadDir'.tr, + hintText: 'aria2DownloadDirHint'.tr, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: filenameTemplateController, + onChanged: (value) => saveDebouncing.debounce(() => downloadSetting.saveAria2FilenameTemplate(value.trim())), + decoration: InputDecoration( + labelText: 'aria2FilenameTemplate'.tr, + hintText: 'aria2FilenameTemplateHint'.tr, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: _showAria2FilenameTemplateHelp, + icon: const Icon(Icons.help_outline), + tooltip: 'aria2FilenameTemplateHelp'.tr, + ), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: timeout, + decoration: InputDecoration( + labelText: 'aria2ConnectTimeout'.tr, + border: const OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 3000, child: Text('3000ms')), + DropdownMenuItem(value: 5000, child: Text('5000ms')), + DropdownMenuItem(value: 8000, child: Text('8000ms')), + DropdownMenuItem(value: 12000, child: Text('12000ms')), + DropdownMenuItem(value: 20000, child: Text('20000ms')), + ], + onChanged: (value) { + if (value != null) { + setState(() => timeout = value); + downloadSetting.saveAria2ConnectTimeout(value); + } + }, + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text('testConnection'.tr), + trailing: _buildTestConnectionTrailing(), + onTap: _testConnection, + ), + ], + ).withListTileTheme(context), + ); + } + + Future _testConnection() async { + if (testConnectionState == LoadingState.loading) { + return; + } + + setState(() => testConnectionState = LoadingState.loading); + await downloadSetting.saveAria2RpcUrl(rpcUrlController.text.trim()); + await downloadSetting.saveAria2Secret(secretController.text.trim()); + await downloadSetting.saveAria2ConnectTimeout(timeout); + + try { + await aria2Service.testConnection(); + if (!mounted) { + return; + } + setState(() => testConnectionState = LoadingState.success); + toast('aria2ConnectionSucceeded'.tr, isCenter: false); + } on DioException catch (e) { + if (!mounted) { + return; + } + setState(() => testConnectionState = LoadingState.error); + snack('aria2ConnectionFailed'.tr, e.message ?? e.toString(), isShort: true); + } catch (e) { + if (!mounted) { + return; + } + setState(() => testConnectionState = LoadingState.error); + snack('aria2ConnectionFailed'.tr, e.toString(), isShort: true); + } + } + + Widget _buildTestConnectionTrailing() { + if (testConnectionState == LoadingState.loading) { + return const CupertinoActivityIndicator(); + } + if (testConnectionState == LoadingState.success) { + return const Icon(Icons.check_circle_outline); + } + if (testConnectionState == LoadingState.error) { + return const Icon(Icons.error_outline); + } + return const Icon(Icons.keyboard_arrow_right); + } + + Future _showAria2FilenameTemplateHelp() async { + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('aria2FilenameTemplateHelp'.tr), + content: Text( + '${'aria2FilenameTemplateOptionGid'.tr}\n' + '${'aria2FilenameTemplateOptionTitle'.tr}\n' + '${'aria2FilenameTemplateOptionUploader'.tr}\n' + '${'aria2FilenameTemplateOptionCategory'.tr}\n\n' + '${'aria2FilenameTemplateExample'.tr}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('ok'.tr), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/setting/download/setting_download_page.dart b/lib/src/pages/setting/download/setting_download_page.dart index 72ff5d54a..c80aa301d 100644 --- a/lib/src/pages/setting/download/setting_download_page.dart +++ b/lib/src/pages/setting/download/setting_download_page.dart @@ -61,6 +61,7 @@ class _SettingDownloadPageState extends State { _buildDefaultGalleryGroup(context), _buildDefaultArchiveGroup(context), _buildArchiveBotSettings(), + _buildAria2Setting(), _buildDownloadConcurrency(), _buildSpeedLimit(context), _buildDownloadAllGallerysOfSamePriority(), @@ -207,6 +208,23 @@ class _SettingDownloadPageState extends State { ); } + Widget _buildAria2Setting() { + String rpcUrl = downloadSetting.aria2RpcUrl.value.trim().isEmpty ? 'aria2RpcUrlHint'.tr : downloadSetting.aria2RpcUrl.value; + String downloadDir = downloadSetting.aria2DownloadDir.value.trim().isEmpty ? 'default'.tr : downloadSetting.aria2DownloadDir.value; + String filenameTemplate = downloadSetting.aria2FilenameTemplate.value.trim().isEmpty ? 'aria2FilenameTemplateHint'.tr : downloadSetting.aria2FilenameTemplate.value; + + return ListTile( + title: Text('aria2Settings'.tr), + subtitle: Text( + '${'aria2RpcUrl'.tr}: ${rpcUrl.breakWord}\n' + '${'aria2DownloadDir'.tr}: ${downloadDir.breakWord}\n' + '${'aria2FilenameTemplate'.tr}: ${filenameTemplate.breakWord}', + ), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () => toRoute(Routes.aria2Settings), + ); + } + Widget _buildSpeedLimit(BuildContext context) { return ListTile( title: Text('speedLimit'.tr), diff --git a/lib/src/routes/routes.dart b/lib/src/routes/routes.dart index e5a04e6f8..b92267f92 100644 --- a/lib/src/routes/routes.dart +++ b/lib/src/routes/routes.dart @@ -50,6 +50,7 @@ import '../pages/setting/account/setting_account_page.dart'; import '../pages/setting/advanced/loglist/log/log_page.dart'; import '../pages/setting/advanced/loglist/log_list_page.dart'; import '../pages/setting/advanced/super_resolution/setting_super_resolution_page.dart'; +import '../pages/setting/download/aria2/aria2_settings_page.dart'; import '../pages/setting/download/archive_bot/archive_bot_settings_page.dart'; import '../pages/setting/preference/block_rule/add_block_rule/configure_blocking_rule_page.dart'; import '../pages/setting/style/page_list_style/setting_page_list_style_page.dart'; @@ -121,6 +122,7 @@ class Routes { static const String extraGalleryScanPath = "/setting_download/extraGalleryScanPath"; static const String archiveBotSettings = '/setting/download/archive_bot'; + static const String aria2Settings = '/setting/download/aria2'; static const String superResolution = "/setting_advanced/superResolution"; static const String logList = "/setting_advanced/logList"; @@ -404,6 +406,12 @@ class Routes { transition: defaultTransition, offAllBefore: false, ), + EHPage( + name: aria2Settings, + page: () => const Aria2SettingsPage().withEscOrFifthButton2BackRightRoute(), + transition: defaultTransition, + offAllBefore: false, + ), EHPage( name: superResolution, page: () => const SettingSuperResolutionPage().withEscOrFifthButton2BackRightRoute(), diff --git a/lib/src/service/archive_download_service.dart b/lib/src/service/archive_download_service.dart index 0655a0a77..6b1cf7a6d 100644 --- a/lib/src/service/archive_download_service.dart +++ b/lib/src/service/archive_download_service.dart @@ -41,9 +41,11 @@ import '../model/gallery_image.dart'; import '../pages/download/grid/mixin/grid_download_page_service_mixin.dart'; import '../utils/archive_util.dart'; import '../utils/file_util.dart'; +import '../utils/toast_util.dart'; import 'jh_service.dart'; import 'log.dart'; import '../utils/snack_util.dart'; +import 'aria2_service.dart'; import 'gallery_download_service.dart'; ArchiveDownloadService archiveDownloadService = ArchiveDownloadService(); @@ -53,6 +55,8 @@ class ArchiveDownloadService extends GetxController with GridBasePageServiceMixi static const String archiveSpeedComputerId = 'archiveSpeedComputerId'; static const int _maxRetryTimes = 3; + static const int _maxAria2ResolvePollingTimes = 12; + static const Duration _aria2ResolvePollingInterval = Duration(milliseconds: 1000); static const String metadataFileName = 'ametadata'; static const int _maxTitleLength = 80; static const int _maxIsolateCountsTotal = 10; @@ -396,6 +400,146 @@ class ArchiveDownloadService extends GetxController with GridBasePageServiceMixi update(['$archiveStatusId::$gid']); } + Future pushArchiveToAria2(ArchiveDownloadedData archive, {bool reParse = false}) async { + if (!aria2Service.isReady) { + snack('aria2PushFailed'.tr, 'aria2NotConfiguredHint'.tr); + return; + } + + String? resolvedDownloadUrl; + + ArchiveDownloadInfo? archiveDownloadInfo = archiveDownloadInfos[archive.gid]; + if (!reParse && archiveDownloadInfo?.downloadUrl != null) { + resolvedDownloadUrl = archiveDownloadInfo!.downloadUrl; + } else { + String? result = await _resolveArchiveDownloadUrlForAria2(archive, reParse: reParse); + if (result == null) { + return; + } + resolvedDownloadUrl = result; + } + + if (resolvedDownloadUrl == null) { + snack('aria2PushFailed'.tr, 'archiveError'.tr); + return; + } + + try { + await aria2Service.addUri( + uri: resolvedDownloadUrl, + out: _buildAria2OutName(archive), + dir: downloadSetting.aria2DownloadDir.value.trim(), + ); + toast('aria2PushSuccess'.trArgs([archive.title]), isCenter: false); + } on DioException catch (e) { + snack('aria2PushFailed'.tr, e.message ?? e.toString(), isShort: true); + } catch (e) { + snack('aria2PushFailed'.tr, e.toString(), isShort: true); + } + } + + Future _resolveArchiveDownloadUrlForAria2( + ArchiveDownloadedData archive, { + bool reParse = false, + }) async { + CancelToken? cancelToken = archiveDownloadInfos[archive.gid]?.cancelToken; + + try { + if (archive.parseSource == ArchiveParseSource.official.code) { + String? downloadPageUrl = archiveDownloadInfos[archive.gid]?.downloadPageUrl; + if (downloadPageUrl == null || downloadPageUrl.isEmpty) { + downloadPageUrl = await _resolveOfficialDownloadPageUrl(archive); + } + if (downloadPageUrl == null) { + return null; + } + + String downloadPath = await retry( + () => ehRequest.get( + url: downloadPageUrl!, + cancelToken: cancelToken, + parser: EHSpiderParser.downloadArchivePage2DownloadUrl, + ), + retryIf: (e) => e is DioException && e.type != DioExceptionType.cancel, + maxAttempts: _maxRetryTimes, + ); + + String downloadUrl = 'https://${Uri.parse(downloadPageUrl).host}$downloadPath'; + return downloadUrl; + } + + if (!archiveBotSetting.isReady) { + snack('aria2PushFailed'.tr, 'pauseDownloadByInvalidArchiveBotKey'.tr); + return null; + } + + ArchiveBotResponse response = await retry( + () => archiveBotRequest.requestResolve( + apiAddress: archiveBotSetting.apiAddress.value, + apiKey: archiveBotSetting.apiKey.value!, + gid: archive.gid, + token: archive.token, + reParse: reParse, + cancelToken: cancelToken, + parser: ArchiveBotResponseParser.commonParse, + ), + retryIf: (e) => e is DioException && e.type != DioExceptionType.cancel, + maxAttempts: _maxRetryTimes, + ); + + if (!response.isSuccess) { + snack('aria2PushFailed'.tr, response.errorMessage, isShort: true); + return null; + } + + ArchiveResolveVO archiveResolveVO = ArchiveResolveVO.fromResponse(response.data); + return archiveResolveVO.url; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + return null; + } + snack('aria2PushFailed'.tr, e.error?.toString() ?? e.message ?? e.toString(), isShort: true); + return null; + } on EHSiteException catch (e) { + snack('aria2PushFailed'.tr, e.message, isShort: true); + return null; + } catch (e) { + snack('aria2PushFailed'.tr, e.toString(), isShort: true); + return null; + } + } + + Future _resolveOfficialDownloadPageUrl(ArchiveDownloadedData archive) async { + CancelToken? cancelToken = archiveDownloadInfos[archive.gid]?.cancelToken; + + for (int i = 0; i < _maxAria2ResolvePollingTimes; i++) { + ArchiveUnlockResult result = await retry( + () => ehRequest.requestUnlockArchive( + url: archive.archivePageUrl.replaceFirst('--', '-'), + isOriginal: archive.isOriginal, + cancelToken: cancelToken, + parser: EHSpiderParser.unlockArchivePage2DownloadArchivePageUrl, + ), + retryIf: (e) => e is DioException && e.type != DioExceptionType.cancel, + maxAttempts: _maxRetryTimes, + ); + + if (!result.success) { + snack('aria2PushFailed'.tr, result.msg, isShort: true); + return null; + } + + if (result.url != null && result.url!.isNotEmpty) { + return result.url!; + } + + await Future.delayed(_aria2ResolvePollingInterval); + } + + snack('aria2PushFailed'.tr, 'archiveError'.tr, isShort: true); + return null; + } + Future batchUpdateArchiveInDatabase(List archives) async { await appDb.transaction(() async { for (ArchiveDownloadedData archive in archives) { @@ -557,6 +701,30 @@ class ArchiveDownloadService extends GetxController with GridBasePageServiceMixi return title; } + String _buildAria2OutName(ArchiveDownloadedData archive) { + String template = downloadSetting.aria2FilenameTemplate.value.trim(); + if (template.isEmpty) { + template = 'ArchiveV2 - {gid} - {title}.zip'; + } + + String out = template + .replaceAll('{gid}', archive.gid.toString()) + .replaceAll('{title}', _computeArchiveTitle(archive.title)) + .replaceAll('{uploader}', _computeArchiveTitle(archive.uploader ?? '')) + .replaceAll('{category}', _computeArchiveTitle(archive.category)); + + out = out.replaceAll(RegExp(r'[/|?,:*"<>\\]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim(); + + if (out.isEmpty) { + out = 'ArchiveV2 - ${archive.gid} - ${_computeArchiveTitle(archive.title)}'; + } + if (!out.toLowerCase().endsWith('.zip')) { + out = '$out.zip'; + } + + return out; + } + String computePackingFileDownloadPath(ArchiveDownloadedData archive) { String title = _computeArchiveTitle(archive.title); diff --git a/lib/src/service/aria2_service.dart b/lib/src/service/aria2_service.dart new file mode 100644 index 000000000..127ad4735 --- /dev/null +++ b/lib/src/service/aria2_service.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:get/get_utils/get_utils.dart'; +import 'package:jhentai/src/setting/download_setting.dart'; + +import 'log.dart'; + +Aria2Service aria2Service = Aria2Service(); + +class Aria2Service { + bool get isReady => downloadSetting.enableAria2Push.value && downloadSetting.aria2RpcUrl.value.trim().isNotEmpty; + + String _normalizedRpcUrl() { + String url = downloadSetting.aria2RpcUrl.value.trim(); + if (url.isEmpty) { + return url; + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://$url'; + } + if (!url.endsWith('/jsonrpc')) { + if (url.endsWith('/')) { + url = '${url}jsonrpc'; + } else { + url = '$url/jsonrpc'; + } + } + return url; + } + + Map _parseJsonRpcResponse(dynamic data) { + if (data is Map) { + return data; + } + if (data is Map) { + return Map.from(data); + } + if (data is String && data.isNotEmpty) { + dynamic decoded = jsonDecode(data); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + } + throw Exception('Invalid aria2 response'); + } + + Future addUri({ + required String uri, + String? out, + String? dir, + }) async { + Dio dio = Dio(BaseOptions( + connectTimeout: Duration(milliseconds: downloadSetting.aria2ConnectTimeout.value), + receiveTimeout: Duration(milliseconds: downloadSetting.aria2ConnectTimeout.value), + )); + + List params = []; + if (downloadSetting.aria2Secret.value.trim().isNotEmpty) { + params.add('token:${downloadSetting.aria2Secret.value.trim()}'); + } + params.add([uri]); + + Map options = {}; + if (out != null && out.isNotEmpty) { + options['out'] = out; + } + if (dir != null && dir.isNotEmpty) { + options['dir'] = dir; + } + if (options.isNotEmpty) { + params.add(options); + } + + Response response = await dio.post( + _normalizedRpcUrl(), + data: { + 'jsonrpc': '2.0', + 'id': DateTime.now().millisecondsSinceEpoch.toString(), + 'method': 'aria2.addUri', + 'params': params, + }, + ); + + Map rpcResult = _parseJsonRpcResponse(response.data); + + if (rpcResult['error'] != null) { + String error = rpcResult['error'] is Map ? (rpcResult['error']['message']?.toString() ?? 'Unknown aria2 error') : 'Unknown aria2 error'; + throw Exception(error); + } + + String? gid = rpcResult['result']?.toString(); + if (gid.isBlank == true) { + throw Exception('Empty aria2 task id'); + } + + log.info('Pushed to aria2, gid: $gid'); + return gid!; + } + + Future testConnection() async { + Dio dio = Dio(BaseOptions( + connectTimeout: Duration(milliseconds: downloadSetting.aria2ConnectTimeout.value), + receiveTimeout: Duration(milliseconds: downloadSetting.aria2ConnectTimeout.value), + )); + + List params = []; + if (downloadSetting.aria2Secret.value.trim().isNotEmpty) { + params.add('token:${downloadSetting.aria2Secret.value.trim()}'); + } + + Response response = await dio.post( + _normalizedRpcUrl(), + data: { + 'jsonrpc': '2.0', + 'id': DateTime.now().millisecondsSinceEpoch.toString(), + 'method': 'aria2.getVersion', + 'params': params, + }, + ); + + Map rpcResult = _parseJsonRpcResponse(response.data); + + if (rpcResult['error'] != null) { + String error = rpcResult['error'] is Map ? (rpcResult['error']['message']?.toString() ?? 'Unknown aria2 error') : 'Unknown aria2 error'; + throw Exception(error); + } + } +} diff --git a/lib/src/setting/download_setting.dart b/lib/src/setting/download_setting.dart index 87516f95c..ef6b45e7b 100644 --- a/lib/src/setting/download_setting.dart +++ b/lib/src/setting/download_setting.dart @@ -32,6 +32,13 @@ class DownloadSetting with JHLifeCircleBeanWithConfigStorage implements JHLifeCi RxBool manageArchiveDownloadConcurrency = true.obs; RxBool deleteArchiveFileAfterDownload = true.obs; RxBool restoreTasksAutomatically = false.obs; + RxBool enableAria2Push = false.obs; + RxString aria2RpcUrl = 'http://127.0.0.1:6800/jsonrpc'.obs; + RxString aria2Secret = ''.obs; + RxString aria2DownloadDir = ''.obs; + RxString aria2FilenameTemplate = 'ArchiveV2 - {gid} - {title}.zip'.obs; + RxBool aria2DefaultPushSelected = false.obs; + RxInt aria2ConnectTimeout = 8000.obs; @override ConfigEnum get configEnum => ConfigEnum.downloadSetting; @@ -63,6 +70,13 @@ class DownloadSetting with JHLifeCircleBeanWithConfigStorage implements JHLifeCi manageArchiveDownloadConcurrency.value = map['manageArchiveDownloadConcurrency'] ?? manageArchiveDownloadConcurrency.value; deleteArchiveFileAfterDownload.value = map['deleteArchiveFileAfterDownload'] ?? deleteArchiveFileAfterDownload.value; restoreTasksAutomatically.value = map['restoreTasksAutomatically'] ?? restoreTasksAutomatically.value; + enableAria2Push.value = map['enableAria2Push'] ?? enableAria2Push.value; + aria2RpcUrl.value = map['aria2RpcUrl'] ?? aria2RpcUrl.value; + aria2Secret.value = map['aria2Secret'] ?? aria2Secret.value; + aria2DownloadDir.value = map['aria2DownloadDir'] ?? aria2DownloadDir.value; + aria2FilenameTemplate.value = map['aria2FilenameTemplate'] ?? aria2FilenameTemplate.value; + aria2DefaultPushSelected.value = map['aria2DefaultPushSelected'] ?? aria2DefaultPushSelected.value; + aria2ConnectTimeout.value = map['aria2ConnectTimeout'] ?? aria2ConnectTimeout.value; } @override @@ -83,6 +97,13 @@ class DownloadSetting with JHLifeCircleBeanWithConfigStorage implements JHLifeCi 'manageArchiveDownloadConcurrency': manageArchiveDownloadConcurrency.value, 'deleteArchiveFileAfterDownload': deleteArchiveFileAfterDownload.value, 'restoreTasksAutomatically': restoreTasksAutomatically.value, + 'enableAria2Push': enableAria2Push.value, + 'aria2RpcUrl': aria2RpcUrl.value, + 'aria2Secret': aria2Secret.value, + 'aria2DownloadDir': aria2DownloadDir.value, + 'aria2FilenameTemplate': aria2FilenameTemplate.value, + 'aria2DefaultPushSelected': aria2DefaultPushSelected.value, + 'aria2ConnectTimeout': aria2ConnectTimeout.value, }); } @@ -198,6 +219,48 @@ class DownloadSetting with JHLifeCircleBeanWithConfigStorage implements JHLifeCi await saveBeanConfig(); } + Future saveEnableAria2Push(bool value) async { + log.debug('saveEnableAria2Push:$value'); + enableAria2Push.value = value; + await saveBeanConfig(); + } + + Future saveAria2RpcUrl(String value) async { + log.debug('saveAria2RpcUrl:$value'); + aria2RpcUrl.value = value; + await saveBeanConfig(); + } + + Future saveAria2Secret(String value) async { + log.debug('saveAria2Secret:${value.isEmpty ? '' : ''}'); + aria2Secret.value = value; + await saveBeanConfig(); + } + + Future saveAria2DownloadDir(String value) async { + log.debug('saveAria2DownloadDir:$value'); + aria2DownloadDir.value = value; + await saveBeanConfig(); + } + + Future saveAria2FilenameTemplate(String value) async { + log.debug('saveAria2FilenameTemplate:$value'); + aria2FilenameTemplate.value = value; + await saveBeanConfig(); + } + + Future saveAria2DefaultPushSelected(bool value) async { + log.debug('saveAria2DefaultPushSelected:$value'); + aria2DefaultPushSelected.value = value; + await saveBeanConfig(); + } + + Future saveAria2ConnectTimeout(int value) async { + log.debug('saveAria2ConnectTimeout:$value'); + aria2ConnectTimeout.value = value; + await saveBeanConfig(); + } + Future _ensureDownloadDirExists() async { try { await Directory(downloadPath.value).create(recursive: true); diff --git a/lib/src/widget/eh_archive_dialog.dart b/lib/src/widget/eh_archive_dialog.dart index 590564caf..103359358 100644 --- a/lib/src/widget/eh_archive_dialog.dart +++ b/lib/src/widget/eh_archive_dialog.dart @@ -6,6 +6,7 @@ import 'package:jhentai/src/extension/dio_exception_extension.dart'; import 'package:jhentai/src/extension/widget_extension.dart'; import 'package:jhentai/src/model/gallery_archive.dart'; import 'package:jhentai/src/setting/archive_bot_setting.dart'; +import 'package:jhentai/src/setting/download_setting.dart'; import 'package:jhentai/src/widget/eh_asset.dart'; import 'package:jhentai/src/widget/eh_group_name_selector.dart'; import 'package:jhentai/src/widget/loading_state_indicator.dart'; @@ -40,12 +41,14 @@ class _EHArchiveDialogState extends State { late List candidates; late GalleryArchive archive; LoadingState loadingState = LoadingState.idle; + bool pushToAria2 = false; @override void initState() { super.initState(); group = widget.currentGroup ?? widget.candidates.firstOrNull ?? 'default'.tr; + pushToAria2 = downloadSetting.enableAria2Push.value && downloadSetting.aria2DefaultPushSelected.value; candidates = List.of(widget.candidates); candidates.remove(group); candidates.insert(0, group); @@ -72,12 +75,36 @@ class _EHArchiveDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ EHGroupNameSelector(candidates: candidates, currentGroup: group, listener: (g) => group = g), + if (downloadSetting.enableAria2Push.value) + _buildPushToAria2Option(), if (archive.creditCount != null && archive.gpCount != null) EHAsset(gpCount: archive.gpCount!, creditCount: archive.creditCount!).marginOnly(top: 12), Expanded(child: _buildButtons().marginOnly(top: 12)), ], ); } + Widget _buildPushToAria2Option() { + return InkWell( + onTap: () => setState(() => pushToAria2 = !pushToAria2), + child: Row( + children: [ + Checkbox( + value: pushToAria2, + onChanged: (value) => setState(() => pushToAria2 = value ?? false), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Expanded( + child: Text( + 'pushArchiveToAria2'.tr, + style: TextStyle(fontSize: UIConfig.archiveDialogDownloadTextSize + 1), + ), + ), + ], + ), + ); + } + Widget _buildButtons() { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -88,7 +115,7 @@ class _EHArchiveDialogState extends State { text: 'resample'.tr, callback: _canAffordDownload(isOriginal: false) ? () => backRoute( - result: (useBot: false, isOriginal: false, size: _computeSizeInBytes(isOriginal: false), group: group), + result: (useBot: false, isOriginal: false, size: _computeSizeInBytes(isOriginal: false), group: group, pushToAria2: pushToAria2), ) : null, ), @@ -98,7 +125,7 @@ class _EHArchiveDialogState extends State { text: 'original'.tr, callback: _canAffordDownload(isOriginal: true) ? () => backRoute( - result: (useBot: false, isOriginal: true, size: _computeSizeInBytes(isOriginal: true), group: group), + result: (useBot: false, isOriginal: true, size: _computeSizeInBytes(isOriginal: true), group: group, pushToAria2: pushToAria2), ) : null, ), @@ -108,7 +135,7 @@ class _EHArchiveDialogState extends State { size: archive.originalSize, icon: const Icon(Icons.smart_toy_outlined), callback: () => backRoute( - result: (useBot: true, isOriginal: true, size: _computeSizeInBytes(isOriginal: true), group: group), + result: (useBot: true, isOriginal: true, size: _computeSizeInBytes(isOriginal: true), group: group, pushToAria2: pushToAria2), ), ), ],