mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 18:10:29 +00:00
feat(tasks): group extension folder downloads (#1594)
This commit is contained in:
1 parent
fc0a1e0ef9
commit
1e36599d52
84 files changed
+6690
-881
No files matched your search
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
|
||||
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: url ?? 'https://example.com/$id'),
|
||||
options: Options(path: '/downloads'),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: rid,
|
||||
res: Resource(
|
||||
name: name,
|
||||
files: List.generate(
|
||||
count,
|
||||
(i) => FileInfo(
|
||||
name: '$i.txt',
|
||||
size: 100 + i,
|
||||
req: Request(url: 'https://example.com/$id/$i'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
..selected.addAll(List.generate(count, (i) => i));
|
||||
|
||||
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
|
||||
|
||||
void main() {
|
||||
test('one named extension group and all independent sources produce exactly two batches', () {
|
||||
final group = source('a')..selected.remove(0);
|
||||
final unnamed = source('b', name: '');
|
||||
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
|
||||
final http = source('d', rid: 'http-rid', count: 1, name: '');
|
||||
final batches = planDownloads([group, unnamed, torrent, http]);
|
||||
expect(batches, hasLength(2));
|
||||
final grouped = batches.first.request;
|
||||
expect(grouped.group, isTrue);
|
||||
expect(grouped.groupName, 'bundle');
|
||||
expect(grouped.opts!.path, '/downloads');
|
||||
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
|
||||
expect(grouped.toJson(), isNot(contains('groupId')));
|
||||
final normal = batches.last.request;
|
||||
expect(normal.group, isFalse);
|
||||
expect(normal.reqs, hasLength(4));
|
||||
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
|
||||
expect(normal.reqs![2].rid, 'bt-rid');
|
||||
expect(normal.reqs![2].opts!.selectFiles, [1]);
|
||||
expect(normal.reqs![3].rid, 'http-rid');
|
||||
expect(normal.reqs![3].req, isNull);
|
||||
});
|
||||
|
||||
test('successful batches are complete even when only some task IDs are returned', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
calls++;
|
||||
return CreateTaskBatchResult(taskIds: ['only-one']);
|
||||
});
|
||||
final sources = [source('a', name: ''), source('b', rid: 'rid')];
|
||||
await planDownloads(sources).single.submit(service(registry));
|
||||
expect(sources.every((s) => s.created && s.createError == null), isTrue);
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('retry only submits failed batches and never appends to the successful group', () async {
|
||||
final submitted = <CreateTaskBatch>[];
|
||||
var failNormal = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted.add(batch);
|
||||
if (!batch.group && failNormal) throw StateError('all failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
|
||||
});
|
||||
final sources = [source('group'), source('normal', name: '')];
|
||||
for (final batch in planDownloads(sources)) {
|
||||
await batch.submit(service(registry));
|
||||
}
|
||||
expect(sources.first.created, isTrue);
|
||||
expect(sources.last.created, isFalse);
|
||||
expect(sources.last.createError, contains('all failed'));
|
||||
failNormal = false;
|
||||
final retry = planDownloads(sources);
|
||||
expect(retry, hasLength(1));
|
||||
await retry.single.submit(service(registry));
|
||||
expect(submitted.where((batch) => batch.group), hasLength(1));
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
});
|
||||
|
||||
test('single extension keeps nested resource directory and empty base remains empty', () {
|
||||
final single = source('single', count: 1, name: 'album');
|
||||
single.result!.res.files.single.path = 'disc1';
|
||||
expect(single.isGroup, isFalse);
|
||||
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
|
||||
final plain = source('plain', count: 1, name: '')..options.path = '';
|
||||
expect(plain.creationItems().single.opts!.path, '');
|
||||
});
|
||||
|
||||
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
|
||||
test('group members use paths relative to their group for parent "$parent"', () {
|
||||
final pending = source('group')..options.path = parent;
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.opts!.path, parent);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
|
||||
expect(pending.options.path, parent);
|
||||
});
|
||||
}
|
||||
|
||||
test('unnamed extension members retain their ordinary batch destination', () {
|
||||
final pending = source('independent', name: '')..options.path = 'downloads';
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isFalse);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
|
||||
});
|
||||
|
||||
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
final members = pending.creationItems();
|
||||
expect(members.first.opts!.size, 100);
|
||||
expect(members.first.opts!.asDefaultPath, isFalse);
|
||||
expect(members.first.opts!.skipAutoCategorize, isTrue);
|
||||
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
|
||||
pending.selected.remove(0);
|
||||
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
|
||||
expect(pending.options.extra as Map, contains('checksum'));
|
||||
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
|
||||
expect(pending.creationItems().single.opts!.size, 0);
|
||||
});
|
||||
|
||||
test('FTP extension members use FTP options and keep their request TLS mode', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
connections: 64,
|
||||
autoExtract: true,
|
||||
archivePassword: 'secret',
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
pending.files[0].req = Request(
|
||||
url: 'ftp://files.example/archive.zip',
|
||||
extra: ReqExtraFtp(tls: 'explicit').toJson(),
|
||||
);
|
||||
pending.selected.remove(1);
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.req!.extra, {'tls': 'explicit'});
|
||||
expect(item.opts!.size, 0);
|
||||
final extra = item.opts!.extra as Map;
|
||||
expect(extra['connections'], 32);
|
||||
expect(extra['autoExtract'], isTrue);
|
||||
expect(extra['archivePassword'], 'secret');
|
||||
expect(extra.containsKey('checksum'), isFalse);
|
||||
expect((pending.options.extra as Map)['connections'], 64);
|
||||
});
|
||||
|
||||
test('member option changes do not affect other members or source options', () {
|
||||
final pending = source('group');
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..size = 500
|
||||
..selectFiles = [0, 1]
|
||||
..asDefaultPath = true;
|
||||
final original = pending.options.toJson();
|
||||
final members = pending.creationItems();
|
||||
members.first.opts!
|
||||
..name = 'changed.txt'
|
||||
..path = '/elsewhere'
|
||||
..size = 1
|
||||
..selectFiles.add(99);
|
||||
expect(members.last.opts!.name, '1.txt');
|
||||
expect(members.last.opts!.path, '');
|
||||
expect(members.last.opts!.size, 101);
|
||||
expect(members.last.opts!.selectFiles, [0, 1]);
|
||||
expect(pending.options.toJson(), original);
|
||||
});
|
||||
|
||||
for (final rid in ['', 'resolved-rid']) {
|
||||
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
|
||||
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..selectFiles = [2];
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.rid, rid.isEmpty ? null : rid);
|
||||
expect(item.req, rid.isEmpty ? pending.request : null);
|
||||
expect(identical(item.opts, pending.options), isFalse);
|
||||
expect(item.opts!.toJson(), pending.options.toJson());
|
||||
});
|
||||
}
|
||||
|
||||
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
|
||||
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
|
||||
expect(pending.isBt, isFalse);
|
||||
expect(pending.isGroup, isTrue);
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.reqs, hasLength(2));
|
||||
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
|
||||
});
|
||||
|
||||
test('root names use HTTP file names, BT resource names and URL fallback', () {
|
||||
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
|
||||
expect(http.name, '0.txt');
|
||||
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
|
||||
bt.result!.res.hash = 'infohash';
|
||||
expect(bt.isBt, isTrue);
|
||||
expect(bt.name, 'torrent');
|
||||
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
|
||||
expect(unnamed.name, unnamed.request.url);
|
||||
});
|
||||
|
||||
for (final entry in <String, bool>{
|
||||
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
|
||||
'https://example.com/file': false,
|
||||
'https://example.com/file.torrent': false,
|
||||
'ftp://files.example/file.torrent': false,
|
||||
'ftps://files.example/file.torrent': false,
|
||||
'FTPES://files.example/file.torrent': false,
|
||||
'magnet:?xt=urn:btih:infohash': true,
|
||||
'MAGNET:?xt=urn:btih:infohash': true,
|
||||
'file:///downloads/file.torrent': true,
|
||||
'/downloads/FILE.TORRENT': true,
|
||||
'file:///downloads/file.torrent?query=1': true,
|
||||
'data:application/x-bittorrent;base64,AAAA': true,
|
||||
'data:application/octet-stream;base64,AAAA': false,
|
||||
}.entries) {
|
||||
test('source protocol determines BT identity for ${entry.key}', () {
|
||||
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
|
||||
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
|
||||
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
|
||||
expect(pending.isBt, entry.value);
|
||||
expect(pending.name, entry.value ? 'resource' : '0.txt');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) {
|
||||
calls++;
|
||||
return ResolveResult(
|
||||
id: 'rid-$calls',
|
||||
res: Resource(files: [FileInfo(name: 'file')]),
|
||||
);
|
||||
});
|
||||
final first = PendingDownload(
|
||||
id: 'a',
|
||||
request: Request(url: 'https://example.com'),
|
||||
options: Options(),
|
||||
);
|
||||
await first.resolve(service(registry));
|
||||
await first.resolve(service(registry));
|
||||
expect(calls, 1);
|
||||
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
|
||||
await reopened.resolve(service(registry));
|
||||
expect(calls, 2);
|
||||
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
|
||||
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('the list counts a group once and sends group IDs for every action', () async {
|
||||
api.Task task(String id) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': '$id.txt',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'parentId': 'group',
|
||||
'protocol': 'http',
|
||||
'status': 'pause',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': '/downloads/folder', 'size': 100},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
final raw = [task('first'), task('second')];
|
||||
final actions = <String>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getTasks, (_) => raw)
|
||||
..bind(GopeedMethods.pauseTask, (id) {
|
||||
actions.add('pause:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTask, (id) {
|
||||
actions.add('resume:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.pauseTasks, (params) {
|
||||
actions.add('pauseBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTasks, (params) {
|
||||
actions.add('resumeBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.deleteTasks, (params) {
|
||||
actions.add('delete:${params['ids']}:${params['force']}');
|
||||
raw.clear();
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final state = await container.read(tasksControllerProvider.future);
|
||||
expect(state.tasks.length, 1);
|
||||
expect(state.tasks.single.members.length, 2);
|
||||
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
|
||||
expect(state.findTask('group'), same(state.tasks.single));
|
||||
expect(state.findTask('first'), same(state.tasks.single.members.first));
|
||||
expect(state.findTask('missing'), isNull);
|
||||
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
|
||||
expect(state.tasks.single.progress, 0.25);
|
||||
final controller = container.read(tasksControllerProvider.notifier);
|
||||
await controller.pause('group');
|
||||
await controller.resume('group');
|
||||
await controller.pauseAll(['group']);
|
||||
await controller.resumeAll(['group']);
|
||||
await controller.deleteSelected(['group'], force: true);
|
||||
expect(actions, [
|
||||
'pause:group',
|
||||
'resume:group',
|
||||
'pauseBatch:[group]',
|
||||
'resumeBatch:[group]',
|
||||
'delete:[group]:true',
|
||||
]);
|
||||
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user