From b176c8dbb814c2f983b774124e400d02510cf80f Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sun, 18 Feb 2024 20:52:25 +0800 Subject: [PATCH] Update task manager --- lib/api/client.dart | 17 +++ lib/api/client.g.dart | 94 +++++++++++++ lib/api/eh.dart | 92 ++++++++++++ lib/api/eh.g.dart | 80 +++++++++++ lib/api/task.dart | 23 +++ lib/api/task.g.dart | 18 +++ lib/components/number_field.dart | 5 +- lib/dialog/new_download_task_page.dart | 187 +++++++++++++++++++++++++ lib/l10n/app_en.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/main.dart | 18 +++ lib/task_manager.dart | 70 ++++++--- lib/utils/parse_url.dart | 16 +++ 13 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 lib/api/eh.dart create mode 100644 lib/api/eh.g.dart create mode 100644 lib/dialog/new_download_task_page.dart create mode 100644 lib/utils/parse_url.dart diff --git a/lib/api/client.dart b/lib/api/client.dart index 7d4d5fb..8a05adc 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -7,9 +7,11 @@ import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; import 'config.dart'; +import 'eh.dart'; import 'gallery.dart'; import 'status.dart'; import 'tags.dart'; +import 'task.dart'; import 'token.dart'; import 'user.dart'; @@ -217,6 +219,21 @@ abstract class _EHApi { Future updateConfig( @Body(nullToAbsent: false) ConfigOptional cfg, {@CancelRequest() CancelToken? cancel}); + + @GET('/eh/metadata') + Future> getMetaInfo( + @Query("gid") List gids, @Query("token") List tokens, + {@CancelRequest() CancelToken? cancel}); + + @PUT('/task') + @MultiPart() + Future> createDownloadTask( + @Part(name: "gid") int gid, + @Part(name: "token") String token, { + @Part(name: "cfg") DownloadConfig? cfg, + @Part(name: "type") String t = "download", + @CancelRequest() CancelToken? cancel, + }); } class EHApi extends __EHApi { diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 6a6199d..4a958b7 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -1020,6 +1020,100 @@ class __EHApi implements _EHApi { return value; } + @override + Future> getMetaInfo( + List gids, + List tokens, { + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = { + r'gid': gids, + r'token': tokens, + }; + 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, + '/eh/metadata', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => EHMetaInfo.fromJson(json as Map), + ); + return value; + } + + @override + Future> createDownloadTask( + int gid, + String token, { + DownloadConfig? cfg, + String t = "download", + 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( + 'token', + token, + )); + _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; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/api/eh.dart b/lib/api/eh.dart new file mode 100644 index 0000000..d5dc643 --- /dev/null +++ b/lib/api/eh.dart @@ -0,0 +1,92 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'api_result.dart'; + +part 'eh.g.dart'; + +@JsonSerializable() +class GalleryMetadataTorrentInfo { + const GalleryMetadataTorrentInfo({ + required this.hash, + required this.added, + required this.name, + required this.tsize, + required this.fsize, + }); + final String hash; + final String added; + final String name; + final String tsize; + final String fsize; + factory GalleryMetadataTorrentInfo.fromJson(Map json) => + _$GalleryMetadataTorrentInfoFromJson(json); + Map toJson() => _$GalleryMetadataTorrentInfoToJson(this); +} + +@JsonSerializable() +class GalleryMetadataSingle { + const GalleryMetadataSingle({ + required this.gid, + required this.token, + required this.archiverKey, + required this.title, + required this.titleJpn, + required this.category, + required this.thumb, + required this.uploader, + required this.posted, + required this.filecount, + required this.filesize, + required this.expunged, + required this.rating, + required this.torrentcount, + required this.torrents, + required this.tags, + this.parentGid, + this.parentKey, + this.firstGid, + this.firstKey, + }); + final int gid; + final String token; + @JsonKey(name: 'archiver_key') + final String archiverKey; + final String title; + @JsonKey(name: 'title_jpn') + final String titleJpn; + final String category; + final String thumb; + final String uploader; + final String posted; + final String filecount; + final int filesize; + final bool expunged; + final String rating; + final String torrentcount; + final List torrents; + final List tags; + @JsonKey(name: 'parent_gid') + final String? parentGid; + @JsonKey(name: 'parent_key') + final String? parentKey; + @JsonKey(name: 'first_gid') + final String? firstGid; + @JsonKey(name: 'first_key') + final String? firstKey; + factory GalleryMetadataSingle.fromJson(Map json) => + _$GalleryMetadataSingleFromJson(json); + Map toJson() => _$GalleryMetadataSingleToJson(this); +} + +class EHMetaInfo { + const EHMetaInfo({ + required this.metas, + }); + final Map> metas; + factory EHMetaInfo.fromJson(Map json) => EHMetaInfo( + metas: json.map((key, value) => MapEntry( + int.parse(key), + ApiResult.fromJson( + value as Map, + (json) => GalleryMetadataSingle.fromJson( + json as Map))))); +} diff --git a/lib/api/eh.g.dart b/lib/api/eh.g.dart new file mode 100644 index 0000000..be59856 --- /dev/null +++ b/lib/api/eh.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'eh.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GalleryMetadataTorrentInfo _$GalleryMetadataTorrentInfoFromJson( + Map json) => + GalleryMetadataTorrentInfo( + hash: json['hash'] as String, + added: json['added'] as String, + name: json['name'] as String, + tsize: json['tsize'] as String, + fsize: json['fsize'] as String, + ); + +Map _$GalleryMetadataTorrentInfoToJson( + GalleryMetadataTorrentInfo instance) => + { + 'hash': instance.hash, + 'added': instance.added, + 'name': instance.name, + 'tsize': instance.tsize, + 'fsize': instance.fsize, + }; + +GalleryMetadataSingle _$GalleryMetadataSingleFromJson( + Map json) => + GalleryMetadataSingle( + gid: json['gid'] as int, + token: json['token'] as String, + archiverKey: json['archiver_key'] as String, + title: json['title'] as String, + titleJpn: json['title_jpn'] as String, + category: json['category'] as String, + thumb: json['thumb'] as String, + uploader: json['uploader'] as String, + posted: json['posted'] as String, + filecount: json['filecount'] as String, + filesize: json['filesize'] as int, + expunged: json['expunged'] as bool, + rating: json['rating'] as String, + torrentcount: json['torrentcount'] as String, + torrents: (json['torrents'] as List) + .map((e) => + GalleryMetadataTorrentInfo.fromJson(e as Map)) + .toList(), + tags: (json['tags'] as List).map((e) => e as String).toList(), + parentGid: json['parent_gid'] as String?, + parentKey: json['parent_key'] as String?, + firstGid: json['first_gid'] as String?, + firstKey: json['first_key'] as String?, + ); + +Map _$GalleryMetadataSingleToJson( + GalleryMetadataSingle instance) => + { + 'gid': instance.gid, + 'token': instance.token, + 'archiver_key': instance.archiverKey, + 'title': instance.title, + 'title_jpn': instance.titleJpn, + 'category': instance.category, + 'thumb': instance.thumb, + 'uploader': instance.uploader, + 'posted': instance.posted, + 'filecount': instance.filecount, + 'filesize': instance.filesize, + 'expunged': instance.expunged, + 'rating': instance.rating, + 'torrentcount': instance.torrentcount, + 'torrents': instance.torrents, + 'tags': instance.tags, + 'parent_gid': instance.parentGid, + 'parent_key': instance.parentKey, + 'first_gid': instance.firstGid, + 'first_key': instance.firstKey, + }; diff --git a/lib/api/task.dart b/lib/api/task.dart index 4ec4706..a5c1727 100644 --- a/lib/api/task.dart +++ b/lib/api/task.dart @@ -234,3 +234,26 @@ class TaskError { _$TaskErrorFromJson(json); Map toJson() => _$TaskErrorToJson(this); } + +@JsonSerializable() +class DownloadConfig { + DownloadConfig ({ + this.maxDownloadImgCount, + this.mpv, + this.downloadOriginalImg, + this.maxRetryCount, + this.removePreviousGallery, + }); + @JsonKey(name: 'max_download_img_count') + int? maxDownloadImgCount; + bool? mpv; + @JsonKey(name: 'download_original_img') + bool? downloadOriginalImg; + @JsonKey(name: 'max_retry_count') + int? maxRetryCount; + @JsonKey(name: 'remove_previous_gallery') + bool? removePreviousGallery; + factory DownloadConfig.fromJson(Map json) => + _$DownloadConfigFromJson(json); + Map toJson() => _$DownloadConfigToJson(this); +} diff --git a/lib/api/task.g.dart b/lib/api/task.g.dart index 20585c1..f27684a 100644 --- a/lib/api/task.g.dart +++ b/lib/api/task.g.dart @@ -144,3 +144,21 @@ Map _$TaskErrorToJson(TaskError instance) => { 'error': instance.error, 'fatal': instance.fatal, }; + +DownloadConfig _$DownloadConfigFromJson(Map json) => + DownloadConfig( + maxDownloadImgCount: json['max_download_img_count'] as int?, + mpv: json['mpv'] as bool?, + downloadOriginalImg: json['download_original_img'] as bool?, + maxRetryCount: json['max_retry_count'] as int?, + removePreviousGallery: json['remove_previous_gallery'] as bool?, + ); + +Map _$DownloadConfigToJson(DownloadConfig instance) => + { + 'max_download_img_count': instance.maxDownloadImgCount, + 'mpv': instance.mpv, + 'download_original_img': instance.downloadOriginalImg, + 'max_retry_count': instance.maxRetryCount, + 'remove_previous_gallery': instance.removePreviousGallery, + }; diff --git a/lib/components/number_field.dart b/lib/components/number_field.dart index e72774f..b275332 100644 --- a/lib/components/number_field.dart +++ b/lib/components/number_field.dart @@ -10,7 +10,8 @@ class NumberFormField extends StatelessWidget { this.initialValue, this.errorMsg, this.decoration, - this.onChanged}); + this.onChanged, + this.controller}); final int? min; final int? max; @@ -18,11 +19,13 @@ class NumberFormField extends StatelessWidget { final String? errorMsg; final InputDecoration? decoration; final Function(int?)? onChanged; + final TextEditingController? controller; @override Widget build(BuildContext context) { final i18n = AppLocalizations.of(context)!; return TextFormField( + controller: controller, initialValue: initialValue?.toString(), keyboardType: TextInputType.number, inputFormatters: [ diff --git a/lib/dialog/new_download_task_page.dart b/lib/dialog/new_download_task_page.dart new file mode 100644 index 0000000..f50bf36 --- /dev/null +++ b/lib/dialog/new_download_task_page.dart @@ -0,0 +1,187 @@ +import 'package:dio/dio.dart'; +import 'package:eh_downloader_flutter/components/number_field.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 '../globals.dart'; +import '../utils/parse_url.dart'; + +final _log = Logger("NewDownloadTaskPage"); + +class NewDownloadTaskPage extends StatefulWidget { + const NewDownloadTaskPage({super.key, this.gid, this.token}); + final int? gid; + final String? token; + + @override + State createState() => _NewDownloadTaskPage(); +} + +class _NewDownloadTaskPage extends State { + final _formKey = GlobalKey(); + late TextEditingController _urlController; + late TextEditingController _gidController; + late TextEditingController _tokenController; + int? _gid; + String? _token; + CancelToken? _cancel; + bool _isCreating = false; + bool _ok = false; + + @override + void initState() { + _urlController = TextEditingController( + text: widget.gid != null && widget.token != null + ? "https://e-hentai.org/g/${widget.gid}/${widget.token}/" + : ""); + _gidController = TextEditingController(text: widget.gid?.toString()); + _tokenController = TextEditingController(text: widget.token); + super.initState(); + } + + @override + void dispose() { + _urlController.dispose(); + _gidController.dispose(); + _tokenController.dispose(); + _cancel?.cancel(); + _gid = widget.gid; + _token = widget.token; + super.dispose(); + } + + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + Future create() async { + if (_gid == null || _token == null || _token!.isEmpty) { + return; + } + try { + _cancel = CancelToken(); + setState(() { + _isCreating = true; + }); + (await api.createDownloadTask(_gid!, _token!, cancel: _cancel)).unwrap(); + _ok = true; + if (!_cancel!.isCancelled) { + setState(() { + _isCreating = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.warning("Failed to create download task:", e); + setState(() { + _isCreating = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + tryInitApi(context); + if (_ok) { + context.canPop() ? context.pop() : context.go("/task_manager"); + } + 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.createDownloadTask, + 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(TextFormField( + controller: _urlController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.galleryURL, + ), + onChanged: (value) { + final match = parseGalleryUrl(value); + if (match != null) { + setState(() { + _gidController.text = match.$1.toString(); + _tokenController.text = match.$2; + _gid = match.$1; + _token = match.$2; + }); + } + }, + )), + _buildWithVecticalPadding(NumberFormField( + controller: _gidController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.gid, + ), + min: 0, + onChanged: (int? value) { + setState(() { + _gid = value; + }); + if (_token != null && _token!.isNotEmpty) { + _urlController.text = + "https://e-hentai.org/g/$_gid/$_token/"; + } + }, + )), + _buildWithVecticalPadding(TextFormField( + controller: _tokenController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.galleryToken, + ), + onChanged: (String value) { + setState(() { + _token = value; + }); + if (_gid != null && _token!.isNotEmpty) { + _urlController.text = + "https://e-hentai.org/g/$_gid/$_token/"; + } + }, + )), + _buildWithVecticalPadding(ElevatedButton( + onPressed: _gid != null && + _token != null && + _token!.isNotEmpty && + !_isCreating + ? () { + create(); + } + : null, + child: Text(i18n.create))), + ], + )))); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e1253b2..cbd0351 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -151,5 +151,8 @@ "failed": "Failed", "allTasks": "All Tasks", "ehMetadataCacheTime": "The time to cache the metadata of the gallery from E-Hentai", - "hour": "hour" + "hour": "hour", + "createDownloadTask": "Create Download Task", + "galleryURL": "Gallery URL", + "galleryToken": "Gallery Token" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4cc91cb..455ffc4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -151,5 +151,8 @@ "failed": "已失败", "allTasks": "所有任务", "ehMetadataCacheTime": "E-Hentai 画廊元数据缓存时间", - "hour": "小时" + "hour": "小时", + "createDownloadTask": "新建下载任务", + "galleryURL": "画廊地址", + "galleryToken": "画廊令牌" } diff --git a/lib/main.dart b/lib/main.dart index 1f0af75..d135338 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'create_root_user.dart'; import 'dialog/dialog_page.dart'; import 'dialog/download_zip_page.dart'; import 'dialog/gallery_details_page.dart'; +import 'dialog/new_download_task_page.dart'; import 'galleries.dart'; import 'gallery.dart'; import 'globals.dart'; @@ -166,6 +167,23 @@ final _router = GoRouter( path: TaskManagerPage.routeName, builder: (context, state) => TaskManagerPage(key: state.pageKey), ), + GoRoute( + path: "/dialog/new_download_task", + pageBuilder: (context, state) { + int? gid; + String? token; + if (state.uri.queryParameters.containsKey("gid")) { + gid = int.tryParse(state.uri.queryParameters["gid"]!); + } + if (state.uri.queryParameters.containsKey("token")) { + token = state.uri.queryParameters["token"]!; + } + return DialogPage( + key: state.pageKey, + builder: (context) { + return NewDownloadTaskPage(gid: gid, token: token); + }); + }) ], ); diff --git a/lib/task_manager.dart b/lib/task_manager.dart index 32bac4e..1afb370 100644 --- a/lib/task_manager.dart +++ b/lib/task_manager.dart @@ -2,6 +2,7 @@ import 'package:enum_flag/enum_flag.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; +import 'api/task.dart'; import 'globals.dart'; enum TaskStatusFilterFlag with EnumFlag { @@ -98,30 +99,65 @@ class _TaskManagerPage extends State )); } + Widget _buildView(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return CustomScrollView( + slivers: [ + SliverAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.taskManager), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + floating: true, + ), + _buildChips(), + ], + ); + } + + Widget _buildAddMenu(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.add), + itemBuilder: (context) { + return [ + PopupMenuItem( + value: TaskType.download, + child: Text(AppLocalizations.of(context)!.createDownloadTask), + ) + ]; + }, + onSelected: (TaskType type) { + if (type == TaskType.download) { + context.push("/dialog/new_download_task"); + } + }); + } + @override Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } final i18n = AppLocalizations.of(context)!; if (isTop(context)) { setCurrentTitle(i18n.taskManager, Theme.of(context).primaryColor.value); } + final size = MediaQuery.of(context).size; return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - context.canPop() ? context.pop() : context.go("/"); - }, - ), - title: Text(i18n.taskManager), - actions: [ - buildThemeModeIcon(context), - buildMoreVertSettingsButon(context), - ], - floating: true, - ), - _buildChips(), + body: Stack( + children: [ + _buildView(context), + Positioned( + bottom: size.height / 10, + right: size.width / 10, + child: _buildAddMenu(context)) ], ), ); diff --git a/lib/utils/parse_url.dart b/lib/utils/parse_url.dart new file mode 100644 index 0000000..33f054b --- /dev/null +++ b/lib/utils/parse_url.dart @@ -0,0 +1,16 @@ +final _galleryRegex = RegExp(r"(g|mpv)/(\d+)/([^/]+)"); + +(int, String)? parseGalleryUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return null; + } + final path = uri.path; + final match = _galleryRegex.firstMatch(path); + if (match == null) { + return null; + } + final gid = int.parse(match.group(2)!); + final token = match.group(3)!; + return (gid, token); +}