Add server settings page

This commit is contained in:
2024-01-01 16:55:11 +08:00
parent 5566af9a66
commit 6644e788de
10 changed files with 695 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import 'package:eh_downloader_flutter/api/file.dart';
import 'package:retrofit/retrofit.dart';
import 'api_result.dart';
import 'config.dart';
import 'gallery.dart';
import 'status.dart';
import 'tags.dart';
@@ -191,6 +192,14 @@ abstract class _EHApi {
{@Part(name: "is_nsfw") bool? isNsfw,
@Part(name: "is_ad") bool? isAd,
@CancelRequest() CancelToken? cancel});
@GET('/config')
Future<Config> getConfig(
{@Query("current") bool? current, @CancelRequest() CancelToken? cancel});
@POST('/config')
Future<UpdateConfigResult> updateConfig(
@Body(nullToAbsent: false) ConfigOptional cfg,
{@CancelRequest() CancelToken? cancel});
}
class EHApi extends __EHApi {

View File

@@ -857,6 +857,71 @@ class __EHApi implements _EHApi {
return value;
}
@override
Future<Config> getConfig({
bool? current,
CancelToken? cancel,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'current': current};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result =
await _dio.fetch<Map<String, dynamic>>(_setStreamType<Config>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/config',
queryParameters: queryParameters,
data: _data,
cancelToken: cancel,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final value = Config.fromJson(_result.data!);
return value;
}
@override
Future<UpdateConfigResult> updateConfig(
ConfigOptional cfg, {
CancelToken? cancel,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final _data = <String, dynamic>{};
_data.addAll(cfg.toJson());
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<UpdateConfigResult>(Options(
method: 'POST',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/config',
queryParameters: queryParameters,
data: _data,
cancelToken: cancel,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final value = UpdateConfigResult.fromJson(_result.data!);
return value;
}
RequestOptions _setStreamType<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||

167
lib/api/config.dart Normal file
View File

@@ -0,0 +1,167 @@
import 'package:json_annotation/json_annotation.dart';
part 'config.g.dart';
enum ThumbnailMethod {
@JsonValue(0)
ffmpegBinary,
@JsonValue(1)
ffmpegApi,
}
@JsonSerializable()
class Config {
Config({
required this.cookies,
this.dbPath,
this.ua,
required this.ex,
required this.base,
required this.maxTaskCount,
required this.mpv,
required this.maxRetryCount,
required this.maxDownloadImgCount,
required this.downloadOriginalImg,
required this.port,
required this.exportZipJpnTitle,
required this.hostname,
this.meiliHost,
this.meiliSearchApiKey,
this.meiliUpdateApiKey,
required this.ffmpegPath,
required this.thumbnailMethod,
required this.thumbnailDir,
required this.removePreviousGallery,
this.imgVerifySecret,
this.meiliHosts,
required this.corsCredentialsHosts,
this.flutterFrontend,
required this.fetchTimeout,
required this.downloadTimeout,
required this.ffprobePath,
});
bool cookies;
@JsonKey(name: 'db_path')
String? dbPath;
String? ua;
bool ex;
String base;
@JsonKey(name: 'max_task_count')
int maxTaskCount;
bool mpv;
@JsonKey(name: 'max_retry_count')
int maxRetryCount;
@JsonKey(name: 'max_download_img_count')
int maxDownloadImgCount;
@JsonKey(name: 'download_original_img')
bool downloadOriginalImg;
int port;
@JsonKey(name: 'export_zip_jpn_title')
bool exportZipJpnTitle;
String hostname;
@JsonKey(name: 'meili_host')
String? meiliHost;
@JsonKey(name: 'meili_search_api_key')
String? meiliSearchApiKey;
@JsonKey(name: 'meili_update_api_key')
String? meiliUpdateApiKey;
@JsonKey(name: 'ffmpeg_path')
String ffmpegPath;
@JsonKey(name: 'thumbnail_method')
ThumbnailMethod thumbnailMethod;
@JsonKey(name: 'thumbnail_dir')
String thumbnailDir;
@JsonKey(name: 'remove_previous_gallery')
bool removePreviousGallery;
@JsonKey(name: 'img_verify_secret')
String? imgVerifySecret;
@JsonKey(name: 'meili_hosts')
Map<String, String>? meiliHosts;
@JsonKey(name: 'cors_credentials_hosts')
List<String> corsCredentialsHosts;
@JsonKey(name: 'flutter_frontend')
String? flutterFrontend;
@JsonKey(name: 'fetch_timeout')
int fetchTimeout;
@JsonKey(name: 'download_timeout')
int downloadTimeout;
@JsonKey(name: 'ffprobe_path')
String ffprobePath;
factory Config.fromJson(Map<String, dynamic> json) => _$ConfigFromJson(json);
Map<String, dynamic> toJson() => _$ConfigToJson(this);
}
@JsonSerializable()
class UpdateConfigResult {
const UpdateConfigResult({
required this.isUnsafe,
});
@JsonKey(name: 'is_unsafe')
final bool isUnsafe;
factory UpdateConfigResult.fromJson(Map<String, dynamic> json) =>
_$UpdateConfigResultFromJson(json);
Map<String, dynamic> toJson() => _$UpdateConfigResultToJson(this);
}
@JsonSerializable()
class ConfigOptional {
ConfigOptional({
this.cookies,
this.dbPath,
this.ua,
this.ex,
this.base,
this.maxTaskCount,
this.mpv,
this.maxRetryCount,
this.maxDownloadImgCount,
this.downloadOriginalImg,
this.port,
this.exportZipJpnTitle,
this.hostname,
this.meiliHost,
this.meiliSearchApiKey,
this.meiliUpdateApiKey,
this.ffmpegPath,
this.thumbnailMethod,
this.thumbnailDir,
this.removePreviousGallery,
this.imgVerifySecret,
this.meiliHosts,
this.corsCredentialsHosts,
this.flutterFrontend,
this.fetchTimeout,
this.downloadTimeout,
this.ffprobePath,
});
String? cookies;
String? dbPath;
String? ua;
bool? ex;
String? base;
int? maxTaskCount;
bool? mpv;
int? maxRetryCount;
int? maxDownloadImgCount;
bool? downloadOriginalImg;
int? port;
bool? exportZipJpnTitle;
String? hostname;
String? meiliHost;
String? meiliSearchApiKey;
String? meiliUpdateApiKey;
String? ffmpegPath;
ThumbnailMethod? thumbnailMethod;
String? thumbnailDir;
bool? removePreviousGallery;
String? imgVerifySecret;
Map<String, String>? meiliHosts;
List<String>? corsCredentialsHosts;
String? flutterFrontend;
int? fetchTimeout;
int? downloadTimeout;
String? ffprobePath;
factory ConfigOptional.fromJson(Map<String, dynamic> json) =>
_$ConfigOptionalFromJson(json);
Map<String, dynamic> toJson() => _$ConfigOptionalToJson(this);
}

154
lib/api/config.g.dart Normal file
View File

@@ -0,0 +1,154 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Config _$ConfigFromJson(Map<String, dynamic> json) => Config(
cookies: json['cookies'] as bool,
dbPath: json['db_path'] as String?,
ua: json['ua'] as String?,
ex: json['ex'] as bool,
base: json['base'] as String,
maxTaskCount: json['max_task_count'] as int,
mpv: json['mpv'] as bool,
maxRetryCount: json['max_retry_count'] as int,
maxDownloadImgCount: json['max_download_img_count'] as int,
downloadOriginalImg: json['download_original_img'] as bool,
port: json['port'] as int,
exportZipJpnTitle: json['export_zip_jpn_title'] as bool,
hostname: json['hostname'] as String,
meiliHost: json['meili_host'] as String?,
meiliSearchApiKey: json['meili_search_api_key'] as String?,
meiliUpdateApiKey: json['meili_update_api_key'] as String?,
ffmpegPath: json['ffmpeg_path'] as String,
thumbnailMethod:
$enumDecode(_$ThumbnailMethodEnumMap, json['thumbnail_method']),
thumbnailDir: json['thumbnail_dir'] as String,
removePreviousGallery: json['remove_previous_gallery'] as bool,
imgVerifySecret: json['img_verify_secret'] as String?,
meiliHosts: (json['meili_hosts'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
corsCredentialsHosts: (json['cors_credentials_hosts'] as List<dynamic>)
.map((e) => e as String)
.toList(),
flutterFrontend: json['flutter_frontend'] as String?,
fetchTimeout: json['fetch_timeout'] as int,
downloadTimeout: json['download_timeout'] as int,
ffprobePath: json['ffprobe_path'] as String,
);
Map<String, dynamic> _$ConfigToJson(Config instance) => <String, dynamic>{
'cookies': instance.cookies,
'db_path': instance.dbPath,
'ua': instance.ua,
'ex': instance.ex,
'base': instance.base,
'max_task_count': instance.maxTaskCount,
'mpv': instance.mpv,
'max_retry_count': instance.maxRetryCount,
'max_download_img_count': instance.maxDownloadImgCount,
'download_original_img': instance.downloadOriginalImg,
'port': instance.port,
'export_zip_jpn_title': instance.exportZipJpnTitle,
'hostname': instance.hostname,
'meili_host': instance.meiliHost,
'meili_search_api_key': instance.meiliSearchApiKey,
'meili_update_api_key': instance.meiliUpdateApiKey,
'ffmpeg_path': instance.ffmpegPath,
'thumbnail_method': _$ThumbnailMethodEnumMap[instance.thumbnailMethod]!,
'thumbnail_dir': instance.thumbnailDir,
'remove_previous_gallery': instance.removePreviousGallery,
'img_verify_secret': instance.imgVerifySecret,
'meili_hosts': instance.meiliHosts,
'cors_credentials_hosts': instance.corsCredentialsHosts,
'flutter_frontend': instance.flutterFrontend,
'fetch_timeout': instance.fetchTimeout,
'download_timeout': instance.downloadTimeout,
'ffprobe_path': instance.ffprobePath,
};
const _$ThumbnailMethodEnumMap = {
ThumbnailMethod.ffmpegBinary: 0,
ThumbnailMethod.ffmpegApi: 1,
};
UpdateConfigResult _$UpdateConfigResultFromJson(Map<String, dynamic> json) =>
UpdateConfigResult(
isUnsafe: json['is_unsafe'] as bool,
);
Map<String, dynamic> _$UpdateConfigResultToJson(UpdateConfigResult instance) =>
<String, dynamic>{
'is_unsafe': instance.isUnsafe,
};
ConfigOptional _$ConfigOptionalFromJson(Map<String, dynamic> json) =>
ConfigOptional(
cookies: json['cookies'] as String?,
dbPath: json['dbPath'] as String?,
ua: json['ua'] as String?,
ex: json['ex'] as bool?,
base: json['base'] as String?,
maxTaskCount: json['maxTaskCount'] as int?,
mpv: json['mpv'] as bool?,
maxRetryCount: json['maxRetryCount'] as int?,
maxDownloadImgCount: json['maxDownloadImgCount'] as int?,
downloadOriginalImg: json['downloadOriginalImg'] as bool?,
port: json['port'] as int?,
exportZipJpnTitle: json['exportZipJpnTitle'] as bool?,
hostname: json['hostname'] as String?,
meiliHost: json['meiliHost'] as String?,
meiliSearchApiKey: json['meiliSearchApiKey'] as String?,
meiliUpdateApiKey: json['meiliUpdateApiKey'] as String?,
ffmpegPath: json['ffmpegPath'] as String?,
thumbnailMethod: $enumDecodeNullable(
_$ThumbnailMethodEnumMap, json['thumbnailMethod']),
thumbnailDir: json['thumbnailDir'] as String?,
removePreviousGallery: json['removePreviousGallery'] as bool?,
imgVerifySecret: json['imgVerifySecret'] as String?,
meiliHosts: (json['meiliHosts'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
corsCredentialsHosts: (json['corsCredentialsHosts'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
flutterFrontend: json['flutterFrontend'] as String?,
fetchTimeout: json['fetchTimeout'] as int?,
downloadTimeout: json['downloadTimeout'] as int?,
ffprobePath: json['ffprobePath'] as String?,
);
Map<String, dynamic> _$ConfigOptionalToJson(ConfigOptional instance) =>
<String, dynamic>{
'cookies': instance.cookies,
'dbPath': instance.dbPath,
'ua': instance.ua,
'ex': instance.ex,
'base': instance.base,
'maxTaskCount': instance.maxTaskCount,
'mpv': instance.mpv,
'maxRetryCount': instance.maxRetryCount,
'maxDownloadImgCount': instance.maxDownloadImgCount,
'downloadOriginalImg': instance.downloadOriginalImg,
'port': instance.port,
'exportZipJpnTitle': instance.exportZipJpnTitle,
'hostname': instance.hostname,
'meiliHost': instance.meiliHost,
'meiliSearchApiKey': instance.meiliSearchApiKey,
'meiliUpdateApiKey': instance.meiliUpdateApiKey,
'ffmpegPath': instance.ffmpegPath,
'thumbnailMethod': _$ThumbnailMethodEnumMap[instance.thumbnailMethod],
'thumbnailDir': instance.thumbnailDir,
'removePreviousGallery': instance.removePreviousGallery,
'imgVerifySecret': instance.imgVerifySecret,
'meiliHosts': instance.meiliHosts,
'corsCredentialsHosts': instance.corsCredentialsHosts,
'flutterFrontend': instance.flutterFrontend,
'fetchTimeout': instance.fetchTimeout,
'downloadTimeout': instance.downloadTimeout,
'ffprobePath': instance.ffprobePath,
};

View File

@@ -16,6 +16,7 @@ class AuthInfo {
bool get checked => _checked;
bool _isChecking = false;
bool get isChecking => _isChecking;
bool? get isAdmin => _user?.isAdmin;
void clear() {
_user = null;

View File

@@ -136,6 +136,7 @@ enum MoreVertSettings {
settings,
markAsNsfw,
markAsSfw,
serverSettings,
}
void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
@@ -155,6 +156,9 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
case MoreVertSettings.markAsSfw:
GalleryPage.maybeOf(context)?.markGalleryAsNsfw(false);
break;
case MoreVertSettings.serverSettings:
context.push("/server_settings");
break;
default:
break;
}
@@ -183,6 +187,11 @@ List<PopupMenuEntry<MoreVertSettings>> buildMoreVertSettings(
value: MoreVertSettings.settings,
child: Text(AppLocalizations.of(context)!.settings)));
}
if (path != "/server_settings" && auth.isAdmin == true) {
list.add(PopupMenuItem(
value: MoreVertSettings.serverSettings,
child: Text(AppLocalizations.of(context)!.serverSettings)));
}
var showNsfw = prefs.getBool("showNsfw") ?? false;
list.add(PopupMenuItem(
child: StatefulBuilder(

View File

@@ -91,5 +91,11 @@
"markAsNsfw": "Mark as NSFW",
"markAsSfw": "Mark as SFW",
"markAsAd": "Mark as Ad",
"markAsNonAd": "Mark as non-Ad"
"markAsNonAd": "Mark as non-Ad",
"serverSettings": "Server Settings",
"useEx": "Use exhentai.org.",
"mpv": "Fetch page data from Multi-Page Viewer.",
"downloadOriginalImg": "Download original images.",
"exportZipJpnTitle": "Use japanese title first when exporting zip.",
"removePreviousGallery": "Remove old galleries which replaced by new ones."
}

View File

@@ -91,5 +91,11 @@
"markAsNsfw": "标记为NSFW",
"markAsSfw": "标记为SFW",
"markAsAd": "标记为广告",
"markAsNonAd": "标记为非广告"
"markAsNonAd": "标记为非广告",
"serverSettings": "服务器设置",
"useEx": "使用 exhentai.org。",
"mpv": "从 Multi-Page Viewer 获取页面数据。",
"downloadOriginalImg": "下载原始画质的图片。",
"exportZipJpnTitle": "导出Zip时优先使用日语标题。",
"removePreviousGallery": "移除被新画廊替代的旧画廊。"
}

View File

@@ -17,6 +17,7 @@ import 'globals.dart';
import 'home.dart';
import 'login.dart';
import 'logs/file.dart';
import 'server_settings.dart';
import 'set_server.dart';
import 'settings.dart';
import 'utils.dart';
@@ -156,6 +157,10 @@ final _router = GoRouter(
return "/";
}
}),
GoRoute(
path: ServerSettingsPage.routeName,
builder: (context, state) => const ServerSettingsPage(),
)
],
);

271
lib/server_settings.dart Normal file
View File

@@ -0,0 +1,271 @@
import 'package:dio/dio.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/config.dart';
import 'globals.dart';
final _log = Logger("ServerSettingsPage");
class ServerSettingsPage extends StatefulWidget {
const ServerSettingsPage({Key? key}) : super(key: key);
static get routeName => "/server_settings";
@override
State<ServerSettingsPage> createState() => _ServerSettingsPage();
}
class _ServerSettingsPage extends State<ServerSettingsPage>
with IsTopWidget2, ThemeModeWidget {
final _formKey = GlobalKey<FormState>();
late bool _isLoading;
late bool _isSaving;
late bool _changed;
late ScrollController _controller;
late ConfigOptional _now;
Config? _config;
Object? _error;
CancelToken? _cancel;
CancelToken? _saveCancel;
Future<void> _fetchData() async {
_cancel = CancelToken();
try {
final config = await api.getConfig(cancel: _cancel);
if (!_cancel!.isCancelled) {
setState(() {
_config = config;
_error = null;
});
}
} catch (e) {
if (!_cancel!.isCancelled) {
_log.warning("Error when fetching config:", e);
setState(() {
_error = e;
});
}
}
}
Future<void> _saveConfig() async {
if (_isSaving) return;
try {
_saveCancel = CancelToken();
setState(() {
_isSaving = true;
});
await api.updateConfig(_now, cancel: _saveCancel);
if (!_saveCancel!.isCancelled) {
setState(() {
_isSaving = false;
_now = ConfigOptional();
_changed = false;
_config = null;
});
}
} catch (e) {
if (!_saveCancel!.isCancelled) {
_log.warning("Error when saving config:", e);
setState(() {
_isSaving = false;
});
}
}
}
@override
void initState() {
super.initState();
_isLoading = false;
_isSaving = false;
_changed = false;
_controller = ScrollController();
_now = ConfigOptional();
}
@override
void dispose() {
_cancel?.cancel();
_formKey.currentState?.dispose();
_controller.dispose();
_saveCancel?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!tryInitApi(context)) {
return Container();
}
final isLoading = _config == null && _error == null;
if (isLoading && !_isLoading) _fetchData();
final i18n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
if (isTop(context)) {
setCurrentTitle(i18n.serverSettings, cs.primary.value);
}
return Scaffold(
appBar: isLoading
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
context.canPop() ? context.pop() : context.go("/");
},
),
title: Text(i18n.serverSettings),
actions: [
buildThemeModeIcon(context),
buildMoreVertSettingsButon(context),
])
: null,
body: isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SelectableText("Error $_error"),
ElevatedButton.icon(
onPressed: () {
_fetchData();
setState(() {
_error = null;
});
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.retry))
]))
: _buildForm(context));
}
Widget _buildForm(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return Form(
key: _formKey,
child: CustomScrollView(
controller: _controller,
slivers: [
SliverAppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
context.canPop() ? context.pop() : context.go("/");
},
),
title: Text(i18n.serverSettings),
actions: [
buildThemeModeIcon(context),
buildMoreVertSettingsButon(context),
]),
SliverList(
delegate: SliverChildListDelegate([
_buildCheckBox(context),
_buildBottomBar(context),
])),
],
));
}
Widget _buildWithHorizontalPadding(BuildContext context, Widget child) {
return Container(
padding: MediaQuery.of(context).size.width > 810
? const EdgeInsets.symmetric(horizontal: 100)
: null,
child: child,
);
}
Widget _buildWithVecticalPadding(Widget child) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: child,
);
}
Widget _buildCheckBox(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return _buildWithHorizontalPadding(
context,
Column(mainAxisSize: MainAxisSize.min, children: [
_buildWithVecticalPadding(CheckboxMenuButton(
value: _now.ex ?? _config!.ex,
onChanged: (b) {
if (b != null) {
setState(() {
_now.ex = b;
_changed = true;
});
}
},
child: Text(i18n.useEx))),
_buildWithVecticalPadding(CheckboxMenuButton(
value: _now.mpv ?? _config!.mpv,
onChanged: (b) {
if (b != null) {
setState(() {
_now.mpv = b;
_changed = true;
});
}
},
child: Text(i18n.mpv))),
_buildWithVecticalPadding(CheckboxMenuButton(
value: _now.downloadOriginalImg ?? _config!.downloadOriginalImg,
onChanged: (b) {
if (b != null) {
setState(() {
_now.downloadOriginalImg = b;
_changed = true;
});
}
},
child: Text(i18n.downloadOriginalImg))),
_buildWithVecticalPadding(CheckboxMenuButton(
value: _now.exportZipJpnTitle ?? _config!.exportZipJpnTitle,
onChanged: (b) {
if (b != null) {
setState(() {
_now.exportZipJpnTitle = b;
_changed = true;
});
}
},
child: Text(i18n.exportZipJpnTitle))),
_buildWithVecticalPadding(CheckboxMenuButton(
value:
_now.removePreviousGallery ?? _config!.removePreviousGallery,
onChanged: (b) {
if (b != null) {
setState(() {
_now.removePreviousGallery = b;
_changed = true;
});
}
},
child: Text(i18n.removePreviousGallery))),
]));
}
Widget _buildBottomBar(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return _buildWithHorizontalPadding(
context,
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildWithVecticalPadding(ElevatedButton.icon(
icon: const Icon(Icons.save),
label: Text(i18n.save),
onPressed: _changed
? () {
_saveConfig();
}
: null)),
],
));
}
}