diff --git a/lib/api/client.dart b/lib/api/client.dart index 85216e4..7f1847a 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:dio/dio.dart'; +import 'package:retrofit/dio.dart'; import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; @@ -200,6 +201,11 @@ abstract class _EHApi { @GET('/gallery/{gid}') Future> getGallery(@Path("gid") int gid, {@CancelRequest() CancelToken? cancel}); + @GET('/gallery/meta/{gids}') + // ignore: unused_element + Future> _getGalleriesMeta(@Path("gids") String gids, + // ignore: unused_element + {@CancelRequest() CancelToken? cancel}); @GET('/gallery/list') Future>> listGalleries( {@Query("all") bool? all, @@ -270,9 +276,18 @@ abstract class _EHApi { @Part(name: "type") String t = "download", @CancelRequest() CancelToken? cancel, }); + @PUT('/task') + @MultiPart() + Future> createExportZipTask(@Part(name: "gid") int gid, + {@Part(name: 'cfg') ExportZipConfig? cfg, + @Part(name: "type") String t = "export_zip", + @CancelRequest() CancelToken? cancel}); @GET('/task/download_cfg') Future> getDefaultDownloadConfig( {@CancelRequest() CancelToken? cancel}); + @GET('/task/export_zip_cfg') + Future> getDefaultExportZipConfig( + {@CancelRequest() CancelToken? cancel}); } class EHApi extends __EHApi { @@ -317,6 +332,11 @@ class EHApi extends __EHApi { return _getFiles(tokens.join(","), cancel: cancel); } + Future> getGalleriesMeta(List gids, + {@CancelRequest() CancelToken? cancel}) { + return _getGalleriesMeta(gids.join(","), cancel: cancel); + } + Future> getTags(List ids, {CancelToken? cancel}) { return _getTags(ids.join(","), cancel: cancel); } diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 1f829e9..58a1eb7 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -884,6 +884,41 @@ class __EHApi implements _EHApi { return value; } + @override + Future> _getGalleriesMeta( + String gids, { + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/gallery/meta/${gids}', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => GMetaInfos.fromJson(json as Map), + ); + return value; + } + @override Future>> listGalleries({ bool? all, @@ -1375,6 +1410,56 @@ class __EHApi implements _EHApi { return value; } + @override + Future> createExportZipTask( + int gid, { + ExportZipConfig? cfg, + String t = "export_zip", + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.fields.add(MapEntry( + 'gid', + gid.toString(), + )); + _data.fields.add(MapEntry( + 'cfg', + jsonEncode(cfg ?? {}), + )); + _data.fields.add(MapEntry( + 'type', + t, + )); + final _result = await _dio + .fetch>(_setStreamType>(Options( + method: 'PUT', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/task', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => Task.fromJson(json as Map), + ); + return value; + } + @override Future> getDefaultDownloadConfig( {CancelToken? cancel}) async { @@ -1408,6 +1493,39 @@ class __EHApi implements _EHApi { return value; } + @override + Future> getDefaultExportZipConfig( + {CancelToken? cancel}) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/task/export_zip_cfg', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => ExportZipConfig.fromJson(json as Map), + ); + return value; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/api/gallery.dart b/lib/api/gallery.dart index 4fbbb91..97a1c69 100644 --- a/lib/api/gallery.dart +++ b/lib/api/gallery.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'api_result.dart'; import '../globals.dart'; part 'gallery.g.dart'; @@ -168,3 +169,15 @@ class GalleryData { _$GalleryDataFromJson(json); Map toJson() => _$GalleryDataToJson(this); } + +class GMetaInfos { + const GMetaInfos({ + required this.metas, + }); + final Map> metas; + factory GMetaInfos.fromJson(Map json) => GMetaInfos( + metas: json.map((key, value) => MapEntry( + int.parse(key), + ApiResult.fromJson(value as Map, + (json) => GMeta.fromJson(json as Map))))); +} diff --git a/lib/api/task.dart b/lib/api/task.dart index 7268fa9..c2f776d 100644 --- a/lib/api/task.dart +++ b/lib/api/task.dart @@ -297,3 +297,23 @@ class DownloadConfig { _$DownloadConfigFromJson(json); Map toJson() => _$DownloadConfigToJson(this); } + +@JsonSerializable() +class ExportZipConfig { + ExportZipConfig({ + this.output, + this.jpnTitle, + this.maxLength, + this.exportAd, + }); + String? output; + @JsonKey(name: 'jpn_title') + bool? jpnTitle; + @JsonKey(name: 'max_length') + int? maxLength; + @JsonKey(name: 'export_ad') + bool? exportAd; + factory ExportZipConfig.fromJson(Map json) => + _$ExportZipConfigFromJson(json); + Map toJson() => _$ExportZipConfigToJson(this); +} diff --git a/lib/api/task.g.dart b/lib/api/task.g.dart index 5480003..77e0ff5 100644 --- a/lib/api/task.g.dart +++ b/lib/api/task.g.dart @@ -174,3 +174,19 @@ Map _$DownloadConfigToJson(DownloadConfig instance) => 'max_retry_count': instance.maxRetryCount, 'remove_previous_gallery': instance.removePreviousGallery, }; + +ExportZipConfig _$ExportZipConfigFromJson(Map json) => + ExportZipConfig( + output: json['output'] as String?, + jpnTitle: json['jpn_title'] as bool?, + maxLength: (json['max_length'] as num?)?.toInt(), + exportAd: json['export_ad'] as bool?, + ); + +Map _$ExportZipConfigToJson(ExportZipConfig instance) => + { + 'output': instance.output, + 'jpn_title': instance.jpnTitle, + 'max_length': instance.maxLength, + 'export_ad': instance.exportAd, + }; diff --git a/lib/components/task.dart b/lib/components/task.dart index 7f292ae..1ea2203 100644 --- a/lib/components/task.dart +++ b/lib/components/task.dart @@ -70,7 +70,8 @@ class _TaskView extends State { Widget _buildText(BuildContext context) { final i18n = AppLocalizations.of(context)!; - if (widget.task.base.type == TaskType.download) { + final typ = widget.task.base.type; + if (typ == TaskType.download) { final gid = widget.task.base.gid; final title = tasks.meta.containsKey(gid) ? tasks.meta[gid]!.preferredTitle @@ -78,6 +79,14 @@ class _TaskView extends State { return Text("${i18n.downloadTask} $title", maxLines: 1, overflow: TextOverflow.ellipsis); } + if (typ == TaskType.exportZip) { + final gid = widget.task.base.gid; + final title = tasks.gmeta.containsKey(gid) + ? tasks.gmeta[gid]!.preferredTitle + : gid.toString(); + return Text("${i18n.exportZipTask} $title", + maxLines: 1, overflow: TextOverflow.ellipsis); + } return Container(); } diff --git a/lib/dialog/new_export_zip_task_page.dart b/lib/dialog/new_export_zip_task_page.dart new file mode 100644 index 0000000..f751872 --- /dev/null +++ b/lib/dialog/new_export_zip_task_page.dart @@ -0,0 +1,229 @@ +import 'package:dio/dio.dart'; +import 'package:eh_downloader_flutter/api/task.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import '../api/gallery.dart'; +import '../components/labeled_checkbox.dart'; +import '../components/number_field.dart'; +import '../globals.dart'; + +final _log = Logger("NewExportZipTaskPage"); + +class NewExportZipTaskPage extends StatefulWidget { + const NewExportZipTaskPage({super.key, this.gid}); + final int? gid; + + static const routeName = "/dialog/new_export_zip_task"; + + @override + State createState() => _NewExportZipTaskPage(); +} + +class _NewExportZipTaskPage extends State { + final _formKey = GlobalKey(); + int? _gid; + CancelToken? _cancel; + CancelToken? _cancel2; + bool _isCreating = false; + bool _ok = false; + ExportZipConfig? _cfg; + ExportZipConfig? _dftCfg; + bool _useCfg = false; + bool _fetched = false; + + @override + void initState() { + _gid = widget.gid; + super.initState(); + } + + @override + void dispose() { + _cancel?.cancel(); + _cancel2?.cancel(); + super.dispose(); + } + + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + Future create() async { + if (_gid == null) return; + try { + _cancel = CancelToken(); + setState(() { + _isCreating = true; + }); + (await api.createExportZipTask(_gid!, + cfg: _useCfg ? _cfg : null, cancel: _cancel)) + .unwrap(); + _ok = true; + if (!_cancel!.isCancelled) { + setState(() { + _isCreating = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.warning("Failed to create export zip task:", e); + setState(() { + _isCreating = false; + }); + } + } + } + + Future fetchDefaultCfg() async { + _fetched = true; + try { + _cancel2 = CancelToken(); + _dftCfg = + (await api.getDefaultExportZipConfig(cancel: _cancel2)).unwrap(); + } catch (e) { + if (!_cancel2!.isCancelled) { + _log.warning("Failed to fetch default export zip config:", e); + } + } + } + + @override + Widget build(BuildContext context) { + tryInitApi(context); + if (_ok) { + WidgetsBinding.instance!.addPostFrameCallback((_) { + context.canPop() ? context.pop() : context.go("/task_manager"); + }); + } + if (!_fetched) fetchDefaultCfg(); + final i18n = AppLocalizations.of(context)!; + final maxWidth = MediaQuery.of(context).size.width; + return Container( + padding: maxWidth < 400 + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 5) + : const EdgeInsets.all(20), + width: maxWidth < 810 ? null : 800, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column(children: [ + Stack( + alignment: Alignment.center, + children: [ + Text( + i18n.createExportZipTask, + style: Theme.of(context).textTheme.headlineSmall, + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go("/task_manager"), + icon: const Icon(Icons.close), + )), + ], + ), + _buildWithVecticalPadding(NumberFormField( + initialValue: _gid, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.gid, + ), + min: 0, + onChanged: (int? value) { + setState(() { + _gid = value; + }); + }, + )), + _buildWithVecticalPadding(LabeledCheckbox( + value: _useCfg, + onChanged: (value) { + if (value != null) { + setState(() { + _useCfg = value; + if (_useCfg && _cfg == null) { + if (_dftCfg != null) { + _cfg = + ExportZipConfig.fromJson(_dftCfg!.toJson()); + } else { + _cfg = ExportZipConfig(); + } + } + }); + } + }, + label: Text(i18n.overwriteDefaultConfig), + )), + _useCfg + ? _buildWithVecticalPadding(TextFormField( + initialValue: _cfg!.output, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.outputDir, + ), + onChanged: (String value) { + setState(() { + _cfg!.output = value.isEmpty ? null : value; + }); + }, + )) + : Container(), + _useCfg + ? _buildWithVecticalPadding(LabeledCheckbox( + value: _cfg!.jpnTitle ?? false, + onChanged: (value) { + if (value != null) { + setState(() { + _cfg!.jpnTitle = value; + }); + } + }, + label: Text(i18n.useTitleJpn), + )) + : Container(), + _useCfg + ? _buildWithVecticalPadding(NumberFormField( + initialValue: _cfg!.maxLength, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.maxZipFilenameLength, + ), + min: 0, + onChanged: (int? value) { + setState(() { + _cfg!.maxLength = value; + }); + }, + )) + : Container(), + _useCfg + ? _buildWithVecticalPadding(LabeledCheckbox( + value: _cfg!.exportAd ?? false, + onChanged: (value) { + if (value != null) { + setState(() { + _cfg!.exportAd = value; + }); + } + }, + label: Text(i18n.exportAd), + )) + : Container(), + _buildWithVecticalPadding(ElevatedButton( + onPressed: _gid != null && !_isCreating + ? () { + create(); + } + : null, + child: Text(i18n.create))), + ])))); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5b88940..9a730f2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -152,7 +152,7 @@ "allTasks": "All Tasks", "ehMetadataCacheTime": "The time to cache the metadata of the gallery from E-Hentai", "hour": "hour", - "createDownloadTask": "Create Download Task", + "createDownloadTask": "Create download task", "galleryURL": "Gallery URL", "galleryToken": "Gallery Token", "randomFileSecret": "The secret of token to access random file without login", @@ -240,5 +240,7 @@ "changedPasswordSuccessfully": "Changed password successfully.", "logout": "Log out", "logoutConfirm": "Do you want to log out?", - "failedLogout": "Failed to log out: " + "failedLogout": "Failed to log out: ", + "createExportZipTask": "Create export as ZIP file task", + "outputDir": "Output directory" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a4716d9..7295b04 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -240,5 +240,7 @@ "changedPasswordSuccessfully": "修改密码成功。", "logout": "退出登录", "logoutConfirm": "是否退出登录", - "failedLogout": "退出登录失败:" + "failedLogout": "退出登录失败:", + "createExportZipTask": "新建导出为ZIP文件任务", + "outputDir": "输出文件夹" } diff --git a/lib/main.dart b/lib/main.dart index 22c011e..9545639 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'dialog/download_zip_page.dart'; import 'dialog/edit_user_page.dart'; import 'dialog/gallery_details_page.dart'; import 'dialog/new_download_task_page.dart'; +import 'dialog/new_export_zip_task_page.dart'; import 'dialog/new_user_page.dart'; import 'dialog/task_page.dart'; import 'globals.dart'; @@ -255,6 +256,19 @@ final _router = GoRouter( path: UserSettingsPage.routeName, builder: (context, state) => UserSettingsPage(key: state.pageKey), ), + GoRoute( + path: NewExportZipTaskPage.routeName, + pageBuilder: (context, state) { + int? gid; + if (state.uri.queryParameters.containsKey("gid")) { + gid = int.tryParse(state.uri.queryParameters["gid"]!); + } + return DialogPage( + key: state.pageKey, + builder: (context) { + return NewExportZipTaskPage(gid: gid); + }); + }), ], ); diff --git a/lib/pages/task_manager.dart b/lib/pages/task_manager.dart index b8bf51e..67beb2a 100644 --- a/lib/pages/task_manager.dart +++ b/lib/pages/task_manager.dart @@ -204,19 +204,26 @@ class _TaskManagerPage extends State } Widget _buildAddMenu(BuildContext context) { + final i18n = AppLocalizations.of(context)!; return PopupMenuButton( icon: const Icon(Icons.add), itemBuilder: (context) { return [ PopupMenuItem( value: TaskType.download, - child: Text(AppLocalizations.of(context)!.createDownloadTask), - ) + child: Text(i18n.createDownloadTask), + ), + PopupMenuItem( + value: TaskType.exportZip, + child: Text(i18n.createExportZipTask), + ), ]; }, onSelected: (TaskType type) { if (type == TaskType.download) { context.push("/dialog/new_download_task"); + } else if (type == TaskType.exportZip) { + context.push("/dialog/new_export_zip_task"); } }); } diff --git a/lib/task.dart b/lib/task.dart index 4de32f5..aaf445d 100644 --- a/lib/task.dart +++ b/lib/task.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'api/eh.dart'; +import 'api/gallery.dart'; import 'api/task.dart'; import 'globals.dart'; import 'utils/websocket.dart'; @@ -19,6 +20,7 @@ class TaskManager { List tasksList = []; Map meta = {}; bool _isFetching = false; + bool _isFetchingGmeta = false; List peddingGids = []; List peddingTokens = []; late Timer _pingTimer; @@ -28,6 +30,8 @@ class TaskManager { bool _waitClosed = false; bool get closed => _closed; int _connectId = 0; + Map gmeta = {}; + List peddingGmetaId = []; void clear() { _connectId++; _channel?.sink.add("{\"type\":\"close\"}"); @@ -38,6 +42,27 @@ class TaskManager { _closed = true; } + void fetchGMeta() async { + if (_isFetchingGmeta) return; + try { + if (peddingGmetaId.isEmpty) return; + _isFetchingGmeta = true; + final re = (await api.getGalleriesMeta(peddingGmetaId)).unwrap(); + for (final e in re.metas.entries) { + if (e.value.ok) { + gmeta[e.key] = e.value.unwrap(); + peddingGmetaId.remove(e.key); + } else { + _log.warning("Gallery id ${e.key}:", e.value.unwrapErr()); + } + } + listener.tryEmit("task_meta_updated", null); + } catch (e) { + _log.warning("Failed to fetch gallery metadatas: $e"); + } + _isFetchingGmeta = false; + } + void fetchMeta() async { if (_isFetching) return; try { @@ -75,6 +100,11 @@ class TaskManager { peddingTokens.add(task.token); } } + if (task.type == TaskType.exportZip && !gmeta.containsKey(task.gid)) { + if (!peddingGmetaId.contains(task.gid)) { + peddingGmetaId.add(task.gid); + } + } if (status == TaskStatus.finished) { tasksList.add(task.id); return; @@ -120,6 +150,7 @@ class TaskManager { } listener.tryEmit("task_list_changed", null); fetchMeta(); + fetchGMeta(); } else if (type == "new_task") { final task = Task.fromJson(data["detail"] as Map); tasks[task.id] = TaskDetail( @@ -129,6 +160,7 @@ class TaskManager { addToTasksList(task, TaskStatus.wait); listener.tryEmit("task_list_changed", null); fetchMeta(); + fetchGMeta(); } else if (type == "task_started") { final task = Task.fromJson(data["detail"] as Map); tasks.update(task.id, (value) { @@ -156,6 +188,7 @@ class TaskManager { }); listener.tryEmit("task_list_changed", null); fetchMeta(); + fetchGMeta(); } } else if (type == "task_progress") { final task =