mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat(protocol): add FTP, FTPS and FTPES downloads (#1569)
This commit is contained in:
1 parent
e0951de270
commit
c0b70d319d
54 files changed
+3850
-38
No files matched your search
@@ -64,6 +64,10 @@ class ProtocolConfig {
|
||||
Ed2kConfig ed2k = Ed2kConfig();
|
||||
HlsConfig hls = HlsConfig();
|
||||
|
||||
/// A server without FTP support sends no `ftp` key; the defaults keep the rest of the config readable.
|
||||
@JsonKey(fromJson: _ftpConfigFromJson)
|
||||
FtpConfig ftp = FtpConfig();
|
||||
|
||||
ProtocolConfig();
|
||||
|
||||
factory ProtocolConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
@@ -91,6 +95,20 @@ class HlsConfig {
|
||||
Map<String, dynamic> toJson() => _$HlsConfigToJson(this);
|
||||
}
|
||||
|
||||
FtpConfig _ftpConfigFromJson(Map<String, dynamic>? json) => json == null ? FtpConfig() : FtpConfig.fromJson(json);
|
||||
|
||||
/// Global FTP defaults. It mirrors the `ftp` protocol config of the server.
|
||||
@JsonSerializable()
|
||||
class FtpConfig {
|
||||
int connections;
|
||||
|
||||
FtpConfig({this.connections = 0});
|
||||
|
||||
factory FtpConfig.fromJson(Map<String, dynamic> json) => _$FtpConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$FtpConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class HttpConfig {
|
||||
String userAgent;
|
||||
|
||||
@@ -67,13 +67,15 @@ ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolCo
|
||||
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
|
||||
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
|
||||
..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map<String, dynamic>)
|
||||
..hls = HlsConfig.fromJson(json['hls'] as Map<String, dynamic>);
|
||||
..hls = HlsConfig.fromJson(json['hls'] as Map<String, dynamic>)
|
||||
..ftp = _ftpConfigFromJson(json['ftp'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) => <String, dynamic>{
|
||||
'http': instance.http.toJson(),
|
||||
'bt': instance.bt.toJson(),
|
||||
'ed2k': instance.ed2k.toJson(),
|
||||
'hls': instance.hls.toJson(),
|
||||
'ftp': instance.ftp.toJson(),
|
||||
};
|
||||
|
||||
HlsConfig _$HlsConfigFromJson(Map<String, dynamic> json) => HlsConfig(
|
||||
@@ -90,6 +92,11 @@ Map<String, dynamic> _$HlsConfigToJson(HlsConfig instance) => <String, dynamic>{
|
||||
'prefetchContentLength': instance.prefetchContentLength,
|
||||
};
|
||||
|
||||
FtpConfig _$FtpConfigFromJson(Map<String, dynamic> json) =>
|
||||
FtpConfig(connections: (json['connections'] as num?)?.toInt() ?? 0);
|
||||
|
||||
Map<String, dynamic> _$FtpConfigToJson(FtpConfig instance) => <String, dynamic>{'connections': instance.connections};
|
||||
|
||||
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
|
||||
userAgent: json['userAgent'] as String? ?? '',
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
|
||||
@@ -67,3 +67,28 @@ class OptsExtraHttp {
|
||||
|
||||
Map<String, dynamic> toJson() => _$OptsExtraHttpToJson(this);
|
||||
}
|
||||
|
||||
/// Options for an FTP task. It mirrors `pkg/protocol/ftp.OptsExtra`. The TLS
|
||||
/// mode of an `ftp://` link is on the request, see `ReqExtraFtp`.
|
||||
@JsonSerializable()
|
||||
class OptsExtraFtp {
|
||||
int connections;
|
||||
bool? autoTorrent;
|
||||
bool? deleteTorrentAfterDownload;
|
||||
bool? autoExtract;
|
||||
String archivePassword;
|
||||
bool deleteAfterExtract;
|
||||
|
||||
OptsExtraFtp({
|
||||
this.connections = 0,
|
||||
this.autoTorrent,
|
||||
this.deleteTorrentAfterDownload,
|
||||
this.autoExtract,
|
||||
this.archivePassword = '',
|
||||
this.deleteAfterExtract = false,
|
||||
});
|
||||
|
||||
factory OptsExtraFtp.fromJson(Map<String, dynamic> json) => _$OptsExtraFtpFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$OptsExtraFtpToJson(this);
|
||||
}
|
||||
@@ -65,3 +65,21 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
|
||||
OptsExtraFtp _$OptsExtraFtpFromJson(Map<String, dynamic> json) => OptsExtraFtp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptsExtraFtpToJson(OptsExtraFtp instance) => <String, dynamic>{
|
||||
'connections': instance.connections,
|
||||
'autoTorrent': ?instance.autoTorrent,
|
||||
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
};
|
||||
@@ -42,6 +42,20 @@ class ReqExtraBt {
|
||||
Map<String, dynamic> toJson() => _$ReqExtraBtToJson(this);
|
||||
}
|
||||
|
||||
/// The request extra of an FTP task. It mirrors `pkg/protocol/ftp.ReqExtra`;
|
||||
/// [tls] only applies to `ftp://` URLs, because `ftps://` always uses
|
||||
/// implicit TLS and `ftpes://` always uses explicit TLS.
|
||||
@JsonSerializable()
|
||||
class ReqExtraFtp {
|
||||
String tls;
|
||||
|
||||
ReqExtraFtp({this.tls = 'none'});
|
||||
|
||||
factory ReqExtraFtp.fromJson(Map<String, dynamic> json) => _$ReqExtraFtpFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ReqExtraFtpToJson(this);
|
||||
}
|
||||
|
||||
enum RequestProxyMode { follow, none, custom }
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -41,6 +41,10 @@ ReqExtraBt _$ReqExtraBtFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
Map<String, dynamic> _$ReqExtraBtToJson(ReqExtraBt instance) => <String, dynamic>{'trackers': instance.trackers};
|
||||
|
||||
ReqExtraFtp _$ReqExtraFtpFromJson(Map<String, dynamic> json) => ReqExtraFtp(tls: json['tls'] as String? ?? 'none');
|
||||
|
||||
Map<String, dynamic> _$ReqExtraFtpToJson(ReqExtraFtp instance) => <String, dynamic>{'tls': instance.tls};
|
||||
|
||||
RequestProxy _$RequestProxyFromJson(Map<String, dynamic> json) => RequestProxy(
|
||||
mode: $enumDecodeNullable(_$RequestProxyModeEnumMap, json['mode']) ?? RequestProxyMode.follow,
|
||||
scheme: json['scheme'] as String? ?? 'http',
|
||||
|
||||
@@ -6,7 +6,7 @@ part 'task.g.dart';
|
||||
|
||||
enum Status { ready, running, pause, wait, error, done }
|
||||
|
||||
enum Protocol { http, bt, ed2k, hls }
|
||||
enum Protocol { http, bt, ed2k, hls, ftp }
|
||||
|
||||
// ExtractStatus enum matching Go backend
|
||||
enum ExtractStatus {
|
||||
|
||||
@@ -40,7 +40,13 @@ const _$StatusEnumMap = {
|
||||
Status.done: 'done',
|
||||
};
|
||||
|
||||
const _$ProtocolEnumMap = {Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k', Protocol.hls: 'hls'};
|
||||
const _$ProtocolEnumMap = {
|
||||
Protocol.http: 'http',
|
||||
Protocol.bt: 'bt',
|
||||
Protocol.ed2k: 'ed2k',
|
||||
Protocol.hls: 'hls',
|
||||
Protocol.ftp: 'ftp',
|
||||
};
|
||||
|
||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
used: (json['used'] as num).toInt(),
|
||||
|
||||
@@ -52,7 +52,8 @@ sealed class TaskStats {
|
||||
final runtime = _map(json['runtime']);
|
||||
if (snapshot.isEmpty && runtime.isEmpty) return null;
|
||||
return switch (protocol) {
|
||||
Protocol.http => HttpTaskStats.fromJson(snapshot),
|
||||
// FTP stats have the same shape as HTTP stats: one entry per connection.
|
||||
Protocol.http || Protocol.ftp => HttpTaskStats.fromJson(snapshot),
|
||||
Protocol.bt => BtTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
|
||||
Protocol.ed2k => Ed2kTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
|
||||
Protocol.hls || null => null,
|
||||
|
||||
@@ -67,6 +67,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
final _maxRunningController = TextEditingController();
|
||||
final _httpUserAgentController = TextEditingController();
|
||||
final _httpConnectionsController = TextEditingController();
|
||||
final _ftpConnectionsController = TextEditingController();
|
||||
final _hlsConnectionsController = TextEditingController();
|
||||
final _hlsMaxRetriesController = TextEditingController();
|
||||
final _btListenPortController = TextEditingController();
|
||||
@@ -101,6 +102,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_maxRunningController,
|
||||
_httpUserAgentController,
|
||||
_httpConnectionsController,
|
||||
_ftpConnectionsController,
|
||||
_hlsConnectionsController,
|
||||
_hlsMaxRetriesController,
|
||||
_btListenPortController,
|
||||
@@ -583,6 +585,23 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
_SettingsBlock(
|
||||
key: const ValueKey('settings-ftp-block'),
|
||||
title: 'FTP',
|
||||
child: _SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
title: context.l10n.connections,
|
||||
child: _NumberSettingControl(
|
||||
fieldKey: const ValueKey('ftp-connections-input'),
|
||||
controller: _ftpConnectionsController,
|
||||
min: 1,
|
||||
max: _ftpMaxConnections,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SettingsBlock(
|
||||
title: 'BitTorrent',
|
||||
child: _SettingsGroup(
|
||||
@@ -1010,6 +1029,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString();
|
||||
_httpUserAgentController.text = _config!.protocolConfig.http.userAgent;
|
||||
_httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString();
|
||||
_ftpConnectionsController.text = _config!.protocolConfig.ftp.connections.clamp(1, _ftpMaxConnections).toString();
|
||||
_hlsConnectionsController.text = _config!.protocolConfig.hls.segmentConnections.clamp(1, 256).toString();
|
||||
_hlsMaxRetriesController.text = _config!.protocolConfig.hls.maxRetries.clamp(0, 30).toString();
|
||||
_btListenPortController.text = _config!.protocolConfig.bt.listenPort.clamp(0, 65535).toString();
|
||||
@@ -1113,6 +1133,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
min: 1,
|
||||
max: 256,
|
||||
);
|
||||
config.protocolConfig.ftp.connections = _boundedInt(
|
||||
_ftpConnectionsController,
|
||||
fallback: config.protocolConfig.ftp.connections,
|
||||
min: 1,
|
||||
max: _ftpMaxConnections,
|
||||
);
|
||||
config.protocolConfig.hls.segmentConnections = _boundedInt(
|
||||
_hlsConnectionsController,
|
||||
fallback: config.protocolConfig.hls.segmentConnections,
|
||||
@@ -2245,3 +2271,7 @@ class _ErrorState extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest number of parallel FTP logins offered, the same limit as on the create task page. FTP servers
|
||||
/// usually allow only a few logins per user.
|
||||
const _ftpMaxConnections = 32;
|
||||
@@ -304,7 +304,7 @@ TaskAssetType _assetType(String name, {required bool isFolder, required api_task
|
||||
return switch (protocol) {
|
||||
api_task.Protocol.bt => TaskAssetType.torrent,
|
||||
api_task.Protocol.ed2k => TaskAssetType.ed2k,
|
||||
api_task.Protocol.hls || api_task.Protocol.http || null => TaskAssetType.file,
|
||||
api_task.Protocol.hls || api_task.Protocol.http || api_task.Protocol.ftp || null => TaskAssetType.file,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
bool? _deleteTorrentAfterDownload;
|
||||
bool? _autoExtract;
|
||||
bool _deleteAfterExtract = false;
|
||||
String _ftpTls = _ftpTlsNone;
|
||||
bool _allLinksFtp = false;
|
||||
bool _hasPlainFtpLink = false;
|
||||
List<DownloadCategory> _downloadCategories = const [];
|
||||
|
||||
bool _showAdvanced = false;
|
||||
@@ -220,6 +223,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
}
|
||||
_recognizeMagnetUri(urlText.trim());
|
||||
_refreshFtpLinks();
|
||||
}
|
||||
|
||||
/// Shows the FTP options (the 32-connection limit and the TLS select) only
|
||||
/// while the input contains FTP links.
|
||||
void _refreshFtpLinks() {
|
||||
final links = Util.textToLines(_urlController.text).map((line) => line.trim()).where((line) => line.isNotEmpty);
|
||||
final allFtp = links.isNotEmpty && links.every((link) => _parseProtocol(link) == _TaskProtocol.ftp);
|
||||
final hasPlainFtp = links.any(_isPlainFtpUrl);
|
||||
if (allFtp == _allLinksFtp && hasPlainFtp == _hasPlainFtpLink) return;
|
||||
if (!mounted) {
|
||||
_allLinksFtp = allFtp;
|
||||
_hasPlainFtpLink = hasPlainFtp;
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_allLinksFtp = allFtp;
|
||||
_hasPlainFtpLink = hasPlainFtp;
|
||||
});
|
||||
}
|
||||
|
||||
void _applyInitialTask(CreateTask? task) {
|
||||
@@ -237,6 +259,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_httpBody = '';
|
||||
_replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''});
|
||||
_trackersController.clear();
|
||||
_ftpTls = _ftpTlsNone;
|
||||
_protocolTab = 0;
|
||||
_urlController.text = req!.url;
|
||||
_initialRawUrl = req.rawUrl;
|
||||
@@ -261,6 +284,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_trackersController.text = extra.trackers.join('\n');
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.ftp:
|
||||
if (req.extra is Map) {
|
||||
final tls = ReqExtraFtp.fromJson(Map<String, dynamic>.from(req.extra! as Map)).tls;
|
||||
if (_ftpTlsModes.contains(tls)) {
|
||||
_ftpTls = tls;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.ed2k:
|
||||
case null:
|
||||
break;
|
||||
@@ -437,10 +468,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
fieldKey: const ValueKey('create-task-connections-input'),
|
||||
controller: _connectionsController,
|
||||
min: 1,
|
||||
max: 256,
|
||||
max: _allLinksFtp ? _ftpMaxConnections : 256,
|
||||
hintText: context.l10n.enterCount,
|
||||
),
|
||||
),
|
||||
if (_hasPlainFtpLink) ...[
|
||||
const SizedBox(height: 16),
|
||||
AppFormRow(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
label: context.l10n.ftpTls,
|
||||
child: _FtpTlsSelect(
|
||||
key: const ValueKey('create-task-ftp-tls'),
|
||||
value: _ftpTls,
|
||||
onChanged: (value) => setState(() => _ftpTls = value),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
AppFormRow(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
@@ -729,7 +772,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_inputUrls().length <= 1) ...[
|
||||
// Checksum verification is HTTP only.
|
||||
if (_inputUrls().length <= 1 && !_allLinksFtp) ...[
|
||||
const SizedBox(height: 14),
|
||||
AppFormPair(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
@@ -964,7 +1008,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
.createTask(
|
||||
CreateTask(
|
||||
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
|
||||
opts: _buildOptions(name: urls.length > 1 ? '' : _renameController.text.trim()),
|
||||
opts: _buildOptions(
|
||||
url: _protocolUrlFor(url),
|
||||
name: urls.length > 1 ? '' : _renameController.text.trim(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -1024,6 +1071,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
extra = ReqExtraBt(trackers: trackers).toJson();
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.ftp:
|
||||
// ftps:// and ftpes:// fix the TLS mode; only a plain ftp:// link carries one.
|
||||
if (_isPlainFtpUrl(protocolUrl ?? url) && _ftpTls != _ftpTlsNone) {
|
||||
extra = ReqExtraFtp(tls: _ftpTls).toJson();
|
||||
}
|
||||
break;
|
||||
case _TaskProtocol.ed2k:
|
||||
case null:
|
||||
break;
|
||||
@@ -1039,7 +1092,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
bool _validateAdvancedOptions() {
|
||||
if (_protocolTab == 0 && _inputUrls().length <= 1) {
|
||||
if (_protocolTab == 0 && _inputUrls().length <= 1 && !_allLinksFtp) {
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
if (checksumExpected.isNotEmpty) {
|
||||
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
|
||||
@@ -1099,6 +1152,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (uppercaseUrl.startsWith('HTTP:') || uppercaseUrl.startsWith('HTTPS:')) {
|
||||
return _TaskProtocol.http;
|
||||
}
|
||||
// Checked before BitTorrent, so ftp://host/file.torrent downloads the file over FTP.
|
||||
if (uppercaseUrl.startsWith('FTP:') || uppercaseUrl.startsWith('FTPS:') || uppercaseUrl.startsWith('FTPES:')) {
|
||||
return _TaskProtocol.ftp;
|
||||
}
|
||||
if (uppercaseUrl.startsWith('MAGNET:') || uppercaseUrl.endsWith('.TORRENT')) {
|
||||
return _TaskProtocol.bt;
|
||||
}
|
||||
@@ -1108,6 +1165,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether [url] is a plain `ftp://` link, the only FTP scheme whose TLS mode is chosen by the user.
|
||||
bool _isPlainFtpUrl(String url) => url.trim().toUpperCase().startsWith('FTP:');
|
||||
|
||||
String _normalizeTaskUrl(String url) {
|
||||
if (_isBtHash(url)) {
|
||||
return 'magnet:?xt=urn:btih:$url';
|
||||
@@ -1130,7 +1190,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
||||
}
|
||||
|
||||
/// Builds the task options for [url]. FTP links get the FTP options, with at
|
||||
/// most [_ftpMaxConnections] connections; the checksum is HTTP only.
|
||||
api_options.Options _buildOptions({
|
||||
required String url,
|
||||
String? name,
|
||||
List<int> selectFiles = const [],
|
||||
bool omitChecksum = false,
|
||||
@@ -1138,18 +1201,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}) {
|
||||
final urls = _inputUrls();
|
||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
|
||||
? api_options.ChecksumOption(algorithm: _checksumAlgorithm, expected: checksumExpected)
|
||||
: null;
|
||||
return api_options.Options(
|
||||
size: urls.length == 1 && _parseProtocol(_protocolUrlFor(urls.first)) == _TaskProtocol.http ? _initialSize : 0,
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
asDefaultPath: _asDefaultPath,
|
||||
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
|
||||
selectFiles: selectFiles,
|
||||
extra: api_options.OptsExtraHttp(
|
||||
final Map<String, dynamic> extra;
|
||||
if (_parseProtocol(url) == _TaskProtocol.ftp) {
|
||||
extra = api_options.OptsExtraFtp(
|
||||
connections: connections.clamp(0, _ftpMaxConnections),
|
||||
autoTorrent: _autoTorrent,
|
||||
deleteTorrentAfterDownload: _deleteTorrentAfterDownload,
|
||||
autoExtract: _autoExtract,
|
||||
archivePassword: _archivePasswordController.text,
|
||||
deleteAfterExtract: _deleteAfterExtract,
|
||||
).toJson();
|
||||
} else {
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
final checksum = (!omitChecksum && urls.length <= 1 && checksumExpected.isNotEmpty)
|
||||
? api_options.ChecksumOption(algorithm: _checksumAlgorithm, expected: checksumExpected)
|
||||
: null;
|
||||
extra = api_options.OptsExtraHttp(
|
||||
connections: connections,
|
||||
autoTorrent: _autoTorrent,
|
||||
deleteTorrentAfterDownload: _deleteTorrentAfterDownload,
|
||||
@@ -1157,7 +1224,16 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
archivePassword: _archivePasswordController.text,
|
||||
deleteAfterExtract: _deleteAfterExtract,
|
||||
checksum: checksum,
|
||||
).toJson(),
|
||||
).toJson();
|
||||
}
|
||||
return api_options.Options(
|
||||
size: urls.length == 1 && _parseProtocol(_protocolUrlFor(urls.first)) == _TaskProtocol.http ? _initialSize : 0,
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
asDefaultPath: _asDefaultPath,
|
||||
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
|
||||
selectFiles: selectFiles,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1232,7 +1308,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
.createTask(
|
||||
CreateTask(
|
||||
rid: result.id,
|
||||
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
|
||||
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -1254,7 +1330,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
skipAutoCategorize: keepStructure,
|
||||
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||
extra: _buildOptions(url: fileRequest.url, omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
@@ -1263,13 +1339,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
reqs: reqs,
|
||||
opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
|
||||
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(gopeedServiceProvider).createTask(CreateTask(req: request, opts: _buildOptions()));
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
req: request,
|
||||
opts: _buildOptions(url: request.url),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
|
||||
@@ -1595,7 +1678,48 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _TaskProtocol { http, bt, ed2k }
|
||||
enum _TaskProtocol { http, bt, ed2k, ftp }
|
||||
|
||||
/// Chooses how a plain `ftp://` link is encrypted. The values are the
|
||||
/// `tls` option of an FTP task.
|
||||
class _FtpTlsSelect extends StatelessWidget {
|
||||
const _FtpTlsSelect({super.key, required this.value, required this.onChanged});
|
||||
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = {
|
||||
_ftpTlsNone: context.l10n.ftpTlsNone,
|
||||
_ftpTlsExplicit: context.l10n.ftpTlsExplicit,
|
||||
_ftpTlsImplicit: context.l10n.ftpTlsImplicit,
|
||||
};
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Select<String>(
|
||||
value: value,
|
||||
constraints: BoxConstraints.tightFor(width: constraints.maxWidth),
|
||||
itemBuilder: (context, selected) =>
|
||||
Text(labels[selected] ?? selected, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
popup: (context) => SelectPopup<String>(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
for (final entry in labels.entries)
|
||||
SelectItemButton<String>(
|
||||
key: ValueKey('create-task-ftp-tls-${entry.key}'),
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (selected) {
|
||||
if (selected != null) onChanged(selected);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirectDownloadToggle extends StatelessWidget {
|
||||
const _DirectDownloadToggle({required this.value, required this.onChanged});
|
||||
@@ -2007,3 +2131,11 @@ const _advancedScrollDuration = Duration(milliseconds: 260);
|
||||
const _advancedScrollExtentRetryDelay = Duration(milliseconds: 40);
|
||||
const _advancedScrollExtentChecks = 4;
|
||||
const _advancedScrollDistance = 112.0;
|
||||
|
||||
/// The highest number of parallel logins offered for FTP links. FTP servers
|
||||
/// usually allow only a few logins per user.
|
||||
const _ftpMaxConnections = 32;
|
||||
const _ftpTlsNone = 'none';
|
||||
const _ftpTlsExplicit = 'explicit';
|
||||
const _ftpTlsImplicit = 'implicit';
|
||||
const _ftpTlsModes = {_ftpTlsNone, _ftpTlsExplicit, _ftpTlsImplicit};
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "حفظ كمجلد التنزيل الافتراضي",
|
||||
"downloadDirValid": "الرجاء تحديد مجلد التنزيل",
|
||||
"connections": "الاتصالات",
|
||||
"ftpTls": "تشفير FTP",
|
||||
"ftpTlsNone": "بدون",
|
||||
"ftpTlsExplicit": "TLS صريح (FTPES)",
|
||||
"ftpTlsImplicit": "TLS ضمني (FTPS)",
|
||||
"maxRetries": "الحد الأقصى لإعادة المحاولات",
|
||||
"useServerCtime": "استخدام وقت الخادم لإنشاء الملف",
|
||||
"maxRunning": "الحد الأقصى للمهام المتزامنة",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Standart endirmə qovluğu kimi yadda saxla",
|
||||
"downloadDirValid": "Zəhmət olmasa endirmə qovluğunu seçin",
|
||||
"connections": "Əlaqələr",
|
||||
"ftpTls": "FTP şifrələməsi",
|
||||
"ftpTlsNone": "Yoxdur",
|
||||
"ftpTlsExplicit": "Açıq TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Gizli TLS (FTPS)",
|
||||
"maxRetries": "Maksimum cəhd sayı",
|
||||
"useServerCtime": "Fayl yaratma tarixi üçün server vaxtından istifadə et",
|
||||
"maxRunning": "Paralel Endirmələr",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Desa com a carpeta de baixades predeterminada",
|
||||
"downloadDirValid": "Seleccioneu la carpeta de baixades",
|
||||
"connections": "Connexions",
|
||||
"ftpTls": "Xifratge FTP",
|
||||
"ftpTlsNone": "Cap",
|
||||
"ftpTlsExplicit": "TLS explícit (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implícit (FTPS)",
|
||||
"maxRetries": "Reintents màxims",
|
||||
"useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers",
|
||||
"maxRunning": "Màxim de tasques en execució",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Als Standard-Downloadordner speichern",
|
||||
"downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis",
|
||||
"connections": "Anschlüsse",
|
||||
"ftpTls": "FTP-Verschlüsselung",
|
||||
"ftpTlsNone": "Keine",
|
||||
"ftpTlsExplicit": "Explizites TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Implizites TLS (FTPS)",
|
||||
"maxRetries": "Maximale Wiederholungsversuche",
|
||||
"useServerCtime": "Serverzeit für die Dateierstellung verwenden",
|
||||
"maxRunning": "Maximale Anzahl ausgeführter Aufgaben",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Save as default download directory",
|
||||
"downloadDirValid": "Please select the download directory",
|
||||
"connections": "Connections",
|
||||
"ftpTls": "FTP encryption",
|
||||
"ftpTlsNone": "None",
|
||||
"ftpTlsExplicit": "Explicit TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Implicit TLS (FTPS)",
|
||||
"maxRetries": "Max retries",
|
||||
"useServerCtime": "Use server time for file creation",
|
||||
"maxRunning": "Concurrent Downloads",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Guardar como carpeta de descargas predeterminada",
|
||||
"downloadDirValid": "Por favor, selecciona el directorio de descarga",
|
||||
"connections": "Conexiones",
|
||||
"ftpTls": "Cifrado FTP",
|
||||
"ftpTlsNone": "Ninguno",
|
||||
"ftpTlsExplicit": "TLS explícito (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implícito (FTPS)",
|
||||
"maxRetries": "Reintentos máximos",
|
||||
"useServerCtime": "Usar tiempo del servidor para la creación de archivos",
|
||||
"maxRunning": "Máximo de Tareas en Ejecución",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "ذخیره بهعنوان پوشه پیشفرض دانلود",
|
||||
"downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید",
|
||||
"connections": "اتصالات",
|
||||
"ftpTls": "رمزگذاری FTP",
|
||||
"ftpTlsNone": "هیچ",
|
||||
"ftpTlsExplicit": "TLS صریح (FTPES)",
|
||||
"ftpTlsImplicit": "TLS ضمنی (FTPS)",
|
||||
"maxRetries": "حداکثر تلاش مجدد",
|
||||
"useServerCtime": "استفاده از زمان سرور برای ایجاد فایل",
|
||||
"maxRunning": "حداکثر وظایف در حال اجرا",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Enregistrer comme dossier de téléchargement par défaut",
|
||||
"downloadDirValid": "Veuillez sélectionner le répertoire de téléchargement",
|
||||
"connections": "Connexions",
|
||||
"ftpTls": "Chiffrement FTP",
|
||||
"ftpTlsNone": "Aucun",
|
||||
"ftpTlsExplicit": "TLS explicite (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implicite (FTPS)",
|
||||
"maxRetries": "Nombre maximal de tentatives",
|
||||
"useServerCtime": "Utiliser l'heure du serveur pour la création de fichier",
|
||||
"maxRunning": "Tâches en cours maximum",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Mentés alapértelmezett letöltési mappaként",
|
||||
"downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat",
|
||||
"connections": "Kapcsolatok",
|
||||
"ftpTls": "FTP-titkosítás",
|
||||
"ftpTlsNone": "Nincs",
|
||||
"ftpTlsExplicit": "Explicit TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Implicit TLS (FTPS)",
|
||||
"maxRetries": "Maximális újrapróbálkozások",
|
||||
"useServerCtime": "Használja a szerveridőt a fájl létrehozásához",
|
||||
"maxRunning": "Maximális futó feladatok",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Simpan sebagai folder unduhan default",
|
||||
"downloadDirValid": "Silahkan pilih direktori unduhan",
|
||||
"connections": "Koneksi",
|
||||
"ftpTls": "Enkripsi FTP",
|
||||
"ftpTlsNone": "Tidak ada",
|
||||
"ftpTlsExplicit": "TLS eksplisit (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implisit (FTPS)",
|
||||
"maxRetries": "Percobaan ulang maks.",
|
||||
"useServerCtime": "Gunakan waktu server untuk pembuatan file",
|
||||
"maxRunning": "Tugas Maksimum yang Berjalan",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Salva come cartella di download predefinita",
|
||||
"downloadDirValid": "Seleziona la directory di scaricamento",
|
||||
"connections": "Connessioni",
|
||||
"ftpTls": "Crittografia FTP",
|
||||
"ftpTlsNone": "Nessuna",
|
||||
"ftpTlsExplicit": "TLS esplicito (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implicito (FTPS)",
|
||||
"maxRetries": "Tentativi massimi",
|
||||
"useServerCtime": "Utilizza l'ora del server per la creazione dei file",
|
||||
"maxRunning": "Numero massimo di attività in esecuzione",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "デフォルトのダウンロード先として保存",
|
||||
"downloadDirValid": "ダウンロードディレクトリを選択してください",
|
||||
"connections": "接続",
|
||||
"ftpTls": "FTP 暗号化",
|
||||
"ftpTlsNone": "なし",
|
||||
"ftpTlsExplicit": "明示的 TLS (FTPES)",
|
||||
"ftpTlsImplicit": "暗黙的 TLS (FTPS)",
|
||||
"maxRetries": "最大リトライ回数",
|
||||
"useServerCtime": "ファイル作成時にサーバー時間を使用する",
|
||||
"maxRunning": "最大実行タスク",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "기본 다운로드 폴더로 저장",
|
||||
"downloadDirValid": "다운로드 폴더를 선택해 주세요",
|
||||
"connections": "연결 수",
|
||||
"ftpTls": "FTP 암호화",
|
||||
"ftpTlsNone": "없음",
|
||||
"ftpTlsExplicit": "명시적 TLS (FTPES)",
|
||||
"ftpTlsImplicit": "암시적 TLS (FTPS)",
|
||||
"maxRetries": "최대 재시도 횟수",
|
||||
"useServerCtime": "파일 생성 시간에 서버 시간 사용",
|
||||
"maxRunning": "최대 동시 작업 수",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Zapisz jako domyślny folder pobierania",
|
||||
"downloadDirValid": "Wskaż folder pobierania",
|
||||
"connections": "Połączenia",
|
||||
"ftpTls": "Szyfrowanie FTP",
|
||||
"ftpTlsNone": "Brak",
|
||||
"ftpTlsExplicit": "Jawne TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Niejawne TLS (FTPS)",
|
||||
"maxRetries": "Maks. liczba ponownych prób",
|
||||
"useServerCtime": "Użyj czasu serwera do utworzenia pliku",
|
||||
"maxRunning": "Maksymalna liczba zadań",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Salvar como pasta de download padrão",
|
||||
"downloadDirValid": "Por favor, selecione a pasta",
|
||||
"connections": "Conexões",
|
||||
"ftpTls": "Criptografia FTP",
|
||||
"ftpTlsNone": "Nenhuma",
|
||||
"ftpTlsExplicit": "TLS explícito (FTPES)",
|
||||
"ftpTlsImplicit": "TLS implícito (FTPS)",
|
||||
"maxRetries": "Tentativas máximas",
|
||||
"useServerCtime": "Usar o tempo de servidor para a criação de ficheiros",
|
||||
"maxRunning": "Máximo de Tarefas Simultâneas",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Сохранить как папку загрузок по умолчанию",
|
||||
"downloadDirValid": "Пожалуйста, выберите папку для загрузки",
|
||||
"connections": "Соединений",
|
||||
"ftpTls": "Шифрование FTP",
|
||||
"ftpTlsNone": "Нет",
|
||||
"ftpTlsExplicit": "Явный TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Неявный TLS (FTPS)",
|
||||
"maxRetries": "Макс. число повторов",
|
||||
"useServerCtime": "Использовать время сервера для создания файла",
|
||||
"maxRunning": "Максимальное количество активных задач",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Uložiť ako predvolený priečinok na stiahnutie",
|
||||
"downloadDirValid": "Vyberte priečinok na stiahnutie",
|
||||
"connections": "Pripojenia",
|
||||
"ftpTls": "Šifrovanie FTP",
|
||||
"ftpTlsNone": "Žiadne",
|
||||
"ftpTlsExplicit": "Explicitné TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Implicitné TLS (FTPS)",
|
||||
"maxRetries": "Maximálny počet pokusov",
|
||||
"useServerCtime": "Použiť čas servera na vytvorenie súboru",
|
||||
"maxRunning": "Súbežné sťahovania",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "இயல்புநிலை பதிவிறக்க கோப்புறையாக சேமி",
|
||||
"downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்",
|
||||
"connections": "தொடர்புகள்",
|
||||
"ftpTls": "FTP குறியாக்கம்",
|
||||
"ftpTlsNone": "இல்லை",
|
||||
"ftpTlsExplicit": "வெளிப்படையான TLS (FTPES)",
|
||||
"ftpTlsImplicit": "மறைமுக TLS (FTPS)",
|
||||
"maxRetries": "அதிகபட்ச மறுமுயற்சிகள்",
|
||||
"useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்",
|
||||
"maxRunning": "அதிகபட்ச இயங்கும் பணிகள்",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Varsayılan indirme klasörü olarak kaydet",
|
||||
"downloadDirValid": "Lütfen indirme dizinini seçin",
|
||||
"connections": "Bağlantılar",
|
||||
"ftpTls": "FTP şifrelemesi",
|
||||
"ftpTlsNone": "Yok",
|
||||
"ftpTlsExplicit": "Açık TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Örtük TLS (FTPS)",
|
||||
"maxRetries": "Maksimum yeniden deneme",
|
||||
"useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan",
|
||||
"maxRunning": "Maksimum Çalışan İşlem",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Зберегти як типову папку завантажень",
|
||||
"downloadDirValid": "Будь ласка, виберіть каталог завантажень",
|
||||
"connections": "З’єднання",
|
||||
"ftpTls": "Шифрування FTP",
|
||||
"ftpTlsNone": "Немає",
|
||||
"ftpTlsExplicit": "Явний TLS (FTPES)",
|
||||
"ftpTlsImplicit": "Неявний TLS (FTPS)",
|
||||
"maxRetries": "Макс. кількість повторів",
|
||||
"useServerCtime": "Використовувати час сервера для створення файлу",
|
||||
"maxRunning": "Макс. завдань у черзі",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "Lưu làm thư mục tải xuống mặc định",
|
||||
"downloadDirValid": "Vui lòng chọn thư mục tải về",
|
||||
"connections": "Kết nối",
|
||||
"ftpTls": "Mã hóa FTP",
|
||||
"ftpTlsNone": "Không",
|
||||
"ftpTlsExplicit": "TLS tường minh (FTPES)",
|
||||
"ftpTlsImplicit": "TLS ngầm định (FTPS)",
|
||||
"maxRetries": "Số lần thử lại tối đa",
|
||||
"useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp",
|
||||
"maxRunning": "Số nhiệm vụ tối đa",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "保存为默认下载目录",
|
||||
"downloadDirValid": "请选择下载目录",
|
||||
"connections": "连接数",
|
||||
"ftpTls": "FTP 加密",
|
||||
"ftpTlsNone": "无",
|
||||
"ftpTlsExplicit": "显式 TLS (FTPES)",
|
||||
"ftpTlsImplicit": "隐式 TLS (FTPS)",
|
||||
"maxRetries": "最大重试次数",
|
||||
"useServerCtime": "创建文件使用服务器时间",
|
||||
"maxRunning": "最大下载数",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"rememberLastDownloadDir": "儲存為預設下載目錄",
|
||||
"downloadDirValid": "請選擇下載目錄",
|
||||
"connections": "連接數",
|
||||
"ftpTls": "FTP 加密",
|
||||
"ftpTlsNone": "無",
|
||||
"ftpTlsExplicit": "明確 TLS (FTPES)",
|
||||
"ftpTlsImplicit": "隱含 TLS (FTPS)",
|
||||
"maxRetries": "最大重試次數",
|
||||
"useServerCtime": "使用伺服器時間作為檔案建立時間",
|
||||
"maxRunning": "最大執行任務數",
|
||||
|
||||
@@ -67,6 +67,25 @@ void main() {
|
||||
expect(decoded.disablePex, isTrue);
|
||||
});
|
||||
|
||||
test('FTP settings round-trip with the Go json keys', () {
|
||||
final config = DownloaderConfig();
|
||||
config.protocolConfig.ftp.connections = 6;
|
||||
|
||||
final json = config.toJson();
|
||||
final ftp = (json['protocolConfig'] as Map<String, dynamic>)['ftp'] as Map<String, dynamic>;
|
||||
expect(ftp, {'connections': 6});
|
||||
expect(DownloaderConfig.fromJson(json).protocolConfig.ftp.connections, 6);
|
||||
});
|
||||
|
||||
test('a stored config without FTP settings still parses', () {
|
||||
final json = DownloaderConfig().toJson();
|
||||
(json['protocolConfig'] as Map<String, dynamic>).remove('ftp');
|
||||
|
||||
final decoded = DownloaderConfig.fromJson(json);
|
||||
expect(decoded.protocolConfig.ftp.connections, 0);
|
||||
expect(decoded.protocolConfig.http.connections, DownloaderConfig().protocolConfig.http.connections);
|
||||
});
|
||||
|
||||
test('BitTorrent peer discovery stays on when a stored config lacks the keys', () {
|
||||
final bt = BtConfig.fromJson({'listenPort': 6881, 'seedRatio': 1.0});
|
||||
expect(bt.disableDht, isFalse);
|
||||
|
||||
@@ -23,6 +23,22 @@ void main() {
|
||||
expect(stats.connections.last.completed, isTrue);
|
||||
});
|
||||
|
||||
test('FTP stats use the HTTP connection layout', () {
|
||||
final stats =
|
||||
TaskStats.fromJson(Protocol.ftp, {
|
||||
'snapshot': {
|
||||
'connections': [
|
||||
{'downloaded': 512, 'total': 1024, 'completed': false, 'failed': false, 'retryTimes': 1},
|
||||
],
|
||||
},
|
||||
'runtime': null,
|
||||
})
|
||||
as HttpTaskStats;
|
||||
|
||||
expect(stats.connections.single.downloaded, 512);
|
||||
expect(stats.connections.single.retryTimes, 1);
|
||||
});
|
||||
|
||||
test('BT stats decode the ordered completion bitset and peer transport', () {
|
||||
final stats =
|
||||
TaskStats.fromJson(Protocol.bt, {
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:gopeed/core/icons/gopeed_icons.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
|
||||
void main() {
|
||||
api.Task buildTask(List<int> selection, int size, {String? doneAt}) => api.Task.fromJson({
|
||||
api.Task buildTask(List<int> selection, int size, {String? doneAt, String protocol = 'bt'}) => api.Task.fromJson({
|
||||
'id': 'bt-selection',
|
||||
'name': 'bundle',
|
||||
'protocol': 'bt',
|
||||
'protocol': protocol,
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
@@ -38,6 +38,13 @@ void main() {
|
||||
expect(record.progress, 0.25);
|
||||
});
|
||||
|
||||
test('tasks with the ftp protocol decode', () {
|
||||
final task = buildTask([], 60, protocol: 'ftp');
|
||||
expect(task.protocol, api.Protocol.ftp);
|
||||
final record = TaskRecord.fromApi(task);
|
||||
expect(record.protocol, api.Protocol.ftp);
|
||||
});
|
||||
|
||||
test('empty selection preserves default all files', () {
|
||||
final record = TaskRecord.fromApi(buildTask([], 60));
|
||||
expect(record.files.length, 3);
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/api/model/resolve_task.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
|
||||
@@ -95,10 +96,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
|
||||
expect(submitted?.reqs?.map((item) => item.req?.url), [
|
||||
'https://example.com/video',
|
||||
'https://example.com/audio',
|
||||
]);
|
||||
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -1603,6 +1601,171 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
group('FTP links', _ftpTests);
|
||||
}
|
||||
|
||||
Future<void> _pumpCreateRoute(WidgetTester tester, CapabilityRegistry registry, {CreateTask? initialTask}) async {
|
||||
tester.view.physicalSize = const Size(1024, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(
|
||||
path: '/create',
|
||||
builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()),
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void _ftpTests() {
|
||||
const urlInput = ValueKey('create-task-url-input');
|
||||
const connectionsInput = ValueKey('create-task-connections-input');
|
||||
const tlsSelect = ValueKey('create-task-ftp-tls');
|
||||
|
||||
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
|
||||
ResolveTask? resolved;
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.resolve, (task) {
|
||||
resolved = task;
|
||||
return ResolveResult(
|
||||
id: 'ftp-resolved',
|
||||
res: Resource(
|
||||
name: 'big.iso',
|
||||
files: [FileInfo(name: 'big.iso', size: 1024)],
|
||||
),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
expect(find.byKey(tlsSelect), findsNothing);
|
||||
|
||||
// ftps:// and ftpes:// fix the TLS mode, so only ftp:// offers the select.
|
||||
for (final url in ['ftps://files.example/pub/big.iso', 'ftpes://files.example/pub/big.iso']) {
|
||||
await tester.enterText(find.byKey(urlInput), url);
|
||||
await tester.pump();
|
||||
expect(find.byKey(tlsSelect), findsNothing, reason: url);
|
||||
}
|
||||
await tester.enterText(find.byKey(urlInput), 'FTP://files.example/pub/big.iso');
|
||||
await tester.pump();
|
||||
expect(find.byKey(tlsSelect), findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('None')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(tlsSelect));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('create-task-ftp-tls-implicit')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-ftp-tls-explicit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('Explicit TLS (FTPES)')), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(connectionsInput), '64');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
// The confirm button spins while the resolve dialog is open, so the frames never settle.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The TLS mode travels on the request, so the resolve already uses it.
|
||||
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
|
||||
expect(resolved?.req?.extra, {'tls': 'explicit'});
|
||||
expect(submitted?.rid, 'ftp-resolved');
|
||||
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
|
||||
expect(createOptions['connections'], 32);
|
||||
expect(createOptions.containsKey('tls'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
|
||||
CreateTask? submitted;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(
|
||||
tester,
|
||||
registry,
|
||||
initialTask: CreateTask(
|
||||
req: Request(
|
||||
url: 'ftp://files.example/pub/linux.torrent',
|
||||
extra: ReqExtraFtp(tls: 'implicit').toJson(),
|
||||
),
|
||||
opts: Options(extra: OptsExtraFtp(connections: 6).toJson()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fieldText(tester, 'create-task-connections-input'), '6');
|
||||
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('Implicit TLS (FTPS)')), findsOneWidget);
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
// The HTTP tab holds the certificate and post-download switches FTP uses; trackers stay hidden.
|
||||
expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget);
|
||||
expect(find.text('Trackers'), findsNothing);
|
||||
// Checksum verification is HTTP only, so an FTP link does not offer it.
|
||||
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
|
||||
expect(submitted?.req?.extra, {'tls': 'implicit'});
|
||||
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
|
||||
expect(options.connections, 6);
|
||||
});
|
||||
|
||||
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
|
||||
final submitted = <CreateTask>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted.add(task);
|
||||
return 'task-${submitted.length}';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
|
||||
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/b.iso');
|
||||
await tester.pump();
|
||||
expect(find.byKey(tlsSelect), findsNothing);
|
||||
await tester.enterText(find.byKey(connectionsInput), '64');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
|
||||
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
|
||||
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
|
||||
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
|
||||
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
|
||||
});
|
||||
}
|
||||
|
||||
/// Unmounts the tree and lets the shadcn toast close timers fire, so a test
|
||||
|
||||
@@ -1631,6 +1631,43 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('settings FTP connections save into the FTP protocol config', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
final settingsController = FakeSettingsController();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
|
||||
settingsControllerProvider.overrideWith(() => settingsController),
|
||||
],
|
||||
child: const _SettingsTestApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final ftpBlock = find.byKey(const ValueKey('settings-ftp-block'));
|
||||
expect(ftpBlock, findsOneWidget);
|
||||
expect(find.descendant(of: ftpBlock, matching: find.text('FTP')), findsOneWidget);
|
||||
final connections = find.byKey(const ValueKey('ftp-connections-input'));
|
||||
await tester.ensureVisible(connections);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(connections, '6');
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
expect(settingsController.state.requireValue.config.protocolConfig.ftp.connections, 6);
|
||||
expect(settingsController.state.requireValue.config.protocolConfig.http.connections, isNot(6));
|
||||
|
||||
// FTP servers usually allow only a few logins, so the field stops at 32.
|
||||
await tester.enterText(connections, '64');
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
expect(tester.widget<shad.TextField>(connections).controller!.text, '32');
|
||||
expect(settingsController.state.requireValue.config.protocolConfig.ftp.connections, 32);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('download directory keeps long text clear of its aligned picker action', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
await tester.pumpWidget(
|
||||
@@ -2589,9 +2626,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dat is already used by 资料'), findsOneWidget);
|
||||
AppPrimaryButton saveButton() => tester.widget<AppPrimaryButton>(
|
||||
find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)),
|
||||
);
|
||||
AppPrimaryButton saveButton() =>
|
||||
tester.widget<AppPrimaryButton>(find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)));
|
||||
expect(saveButton().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv');
|
||||
|
||||
Reference in new issue
Block a user