JJ_MKVMaker/lib/platform/windows/aria2_backend.dart

342 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:path/path.dart' as p;
import '../../core/download_detect.dart';
import '../../services/downloader.dart';
import '../../l10n/tr.dart';
import '../../app/net_guard.dart';
import 'ytdlp_backend.dart' show locateTool;
/// aria2 로 토렌트 · 마그넷 다운로드.
///
/// aria2c 를 RPC 모드(127.0.0.1 전용, 비밀 토큰)로 한 번 띄우고 JSON-RPC 로 제어한다.
/// 받기가 끝나면 올려 주기(시딩)는 바로 멈춘다 (--seed-time=0).
class Aria2Backend implements DownloadBackend {
final String aria2c;
Process? _daemon;
int? _port;
late final String _secret = _randomToken();
final _client = HttpClient();
Timer? _poll;
final _tasks = <String, (DownloadTask, void Function())>{};
/// 추가 환경 변수 · 인수 (Android: 라이브러리 위치 · 인증서 파일)
final Map<String, String> environment;
final List<String> extraArgs;
/// 마그넷 · 토렌트가 이 시간 동안 메타데이터 · 피어를 못 받으면 그 줄에 알린다 (환경 설정, 0 = 알리지 않음)
final Duration Function() stallAfter;
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const [], Duration Function()? stallAfter})
: aria2c = aria2c ?? locateTool('aria2c'),
stallAfter = stallAfter ?? (() => const Duration(seconds: 60));
@override
DownloadKind get kind => DownloadKind.torrent;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
static String _randomToken() {
final r = math.Random.secure();
return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join();
}
Future<void> _ensureDaemon() async {
if (_daemon != null) return;
// 번호를 넘겨 aria2 가 직접 여는 방식이라, 닫고 넘기는 사이에 다른 프로그램이 가져가면 aria2 가 끝난다 - 그때는 새 번호로 몇 번 다시
for (var attempt = 0;; attempt++) {
try {
return await _startDaemon();
} on SocketException {
if (attempt >= 2) rethrow;
}
}
}
Future<void> _startDaemon() async {
// 빈 포트 고르기 (OS 가 고른 번호 - Windows 가 막아 둔 범위에는 걸리지 않는다)
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_port = s.port;
await s.close();
// DNS 강제 (환경 설정 > 네트워크 · DNS): http · 트래커는 앱 안 프록시로, UDP 트래커 · DHT 의 이름은 정한 서버로
final dns = NetGuard.instance.aria2Args(asyncDns: await _hasAsyncDns());
_daemon = await Process.start(aria2c, [
'--enable-rpc', '--rpc-listen-all=false', '--rpc-listen-port=$_port',
'--rpc-secret=$_secret',
// 동시에 받는 수는 앱이 정한다 (환경 설정 > 다운로드 > 동시에 받는 수) - aria2 는 따로 줄 세우지 않게 넉넉히
'--seed-time=0', '--continue=true', '--max-concurrent-downloads=100',
'--bt-save-metadata=false', '--follow-torrent=mem',
'--console-log-level=warn', '--summary-interval=0',
'--file-allocation=none', '--auto-file-renaming=false',
// 28 확인 (10/20): 인터넷 IPv6 가 없는데 (Tailscale fd7a:… 같은 ULA 뿐) IPv6 가 있다고 보이면 aria2 가 트래커 · 피어에
// IPv6 로 먼저 붙다 실패하고 IPv4 로 다시 시도하지 않아 마그넷이 시작되지 않았다 - 그럴 때만 IPv6 를 끈다
if (!await _internetIpv6()) '--disable-ipv6=true',
...extraArgs,
...dns,
], environment: environment.isEmpty ? null : environment);
_daemon!.stdout.drain<void>();
_daemon!.stderr.drain<void>();
// 이 프로세스가 끝나면 (다시 띄운 새 것은 건드리지 않게)
final proc = _daemon!;
unawaited(proc.exitCode.then((_) {
if (identical(_daemon, proc)) _daemon = null;
}));
// RPC 준비 대기 (aria2 가 그 사이 끝났으면 - 포트를 열지 못함 등 - 기다리지 않는다)
for (var i = 0; i < 50 && _daemon != null; i++) {
try {
await _call('aria2.getVersion', []);
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
_daemon?.kill();
_daemon = null;
throw SocketException(tr('aria2 RPC 에 연결할 수 없습니다.'));
}
/// 이 aria2c 가 자체 DNS (c-ares, --async-dns-server) 를 갖췄는지 (없는 옵션을 주면 시작하지 못한다)
Future<bool> _hasAsyncDns() async {
try {
final r = await Process.run(aria2c, ['-v'], environment: environment.isEmpty ? null : environment);
return '${r.stdout}'.contains('Async DNS');
} catch (_) {
return false;
}
}
Future<dynamic> _call(String method, List<Object?> params) async {
final payload = utf8.encode(jsonEncode({
'jsonrpc': '2.0', 'id': 'jj', 'method': method,
'params': ['token:$_secret', ...params],
}));
final req = await _client.postUrl(Uri.parse('http://127.0.0.1:$_port/jsonrpc'));
req.headers.contentType = ContentType.json;
// aria2 는 chunked 전송을 해석하지 못하므로 길이를 명시
req.contentLength = payload.length;
req.add(payload);
final res = await req.close();
final body = jsonDecode(await res.transform(utf8.decoder).join()) as Map;
if (body['error'] != null) throw Exception((body['error'] as Map)['message']);
return body['result'];
}
@override
Future<void> start(DownloadTask t, void Function() changed) async {
await Directory(t.dir).create(recursive: true);
try {
await _ensureDaemon();
final gid = t.extra['gid'] as String?;
if (gid != null) {
await _call('aria2.unpause', [gid]);
} else {
t.extra['gid'] = await _call('aria2.addUri', [
[t.source],
{'dir': t.dir},
]) as String;
}
t
..state = DownloadState.downloading
..error = null;
_tasks[t.id] = (t, changed);
_poll ??= Timer.periodic(const Duration(seconds: 1), (_) => _refresh());
} catch (e) {
t
..state = DownloadState.failed
..error = trf('aria2 오류: {0}', [e]);
}
changed();
}
/// 28 확인: 이 기기의 인터넷 IPv6 · IPv4 (모르면 있다고 본다 - aria2 기본대로)
static Future<bool> _internetIpv6() async {
try {
final l = await NetworkInterface.list(type: InternetAddressType.IPv6, includeLinkLocal: false);
return hasInternetIpv6(l.expand((i) => i.addresses));
} catch (_) {
return true;
}
}
static Future<bool> _internetIpv4() async {
try {
final l = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLinkLocal: false);
return hasInternetIpv4(l.expand((i) => i.addresses));
} catch (_) {
return true;
}
}
/// 멈춘 마그넷 · 토렌트의 이유 후보 (화면 글은 downloads_page 의 [stallReasonText])
static Future<List<String>> _stallReasons() async => [
if (!await _internetIpv4()) 'ipv6only',
'firewall',
if (NetGuard.instance.forcing) 'dns',
];
Future<void> _refresh() async {
for (final (t, changed) in _tasks.values.toList()) {
final gid = t.extra['gid'] as String?;
if (gid == null || t.state != DownloadState.downloading) continue;
try {
final s = await _call('aria2.tellStatus', [
gid,
['status', 'totalLength', 'completedLength', 'downloadSpeed', 'followedBy',
'bittorrent', 'files', 'errorMessage', 'connections'],
]) as Map;
// 마그넷: 메타데이터를 받은 뒤 실제 다운로드로 넘어감
final followed = s['followedBy'] as List?;
if (followed != null && followed.isNotEmpty) {
t.extra['gid'] = followed.first as String;
continue;
}
final total = int.tryParse('${s['totalLength']}') ?? 0;
final done = int.tryParse('${s['completedLength']}') ?? 0;
final speed = int.tryParse('${s['downloadSpeed']}') ?? 0;
final name = ((s['bittorrent'] as Map?)?['info'] as Map?)?['name'] as String?;
if (name != null && name.isNotEmpty) t.title = name;
for (final f in (s['files'] as List? ?? const [])) {
final path = (f as Map)['path'] as String? ?? '';
if (path.isNotEmpty && !path.startsWith('[METADATA]')) t.files.add(path);
}
if (t.title == t.source && t.files.isNotEmpty) t.title = p.basename(t.files.first);
t
..progress = total > 0 ? done / total : null
..receivedBytes = total > 0 ? done : null
..totalBytes = total > 0 ? total : null
..speed = speed > 0 ? '${formatBytes(speed)}/s' : ''
..eta = speed > 0 && total > done ? _eta((total - done) ~/ speed) : '';
// 35: aria2 가 순서를 기다리게 한 것은 "받는 중" 이 아니라 "대기 중" 으로
t.extra['waiting'] = s['status'] == 'waiting';
// 관리자 지시 (10/20): 마그넷 · 토렌트가 메타데이터나 피어를 정한 시간 동안 못 받으면 그 줄에 알린다
// (예전에는 아무 표시 없이 "받는 중" 이라 왜 안 되는지 알 수 없었다). 받은 것이 생기면 지운다
final torrent = s['bittorrent'] != null || t.source.toLowerCase().startsWith('magnet:');
final noInfo = ((s['bittorrent'] as Map?)?['info']) == null;
final conns = int.tryParse('${s['connections']}') ?? 0;
final after = stallAfter();
if (torrent && s['status'] == 'active' && done == 0 && (noInfo || conns == 0) && after > Duration.zero) {
final now = DateTime.now().millisecondsSinceEpoch;
final since = (t.extra['stallSince'] as int?) ?? now;
t.extra['stallSince'] = since;
if (now - since >= after.inMilliseconds && t.extra['stalled'] != true) {
t.extra['stalled'] = true;
t.extra['stallReasons'] = await _stallReasons();
}
} else {
t.extra
..remove('stallSince')
..remove('stalled')
..remove('stallReasons');
}
switch (s['status']) {
case 'complete':
t
..state = DownloadState.done
..progress = 1
..speed = ''
..eta = '';
_tasks.remove(t.id);
case 'error':
t
..state = DownloadState.failed
// 136: 원문 + 오류 번호 (화면은 friendlyDownloadError 로 사람 말 이유)
..error = '${s['errorMessage']} (errorCode=${s['errorCode']})';
_tasks.remove(t.id);
case 'removed':
_tasks.remove(t.id);
}
changed();
} catch (_) {
// 일시적인 RPC 오류는 다음 주기에 다시 시도
}
}
}
static String _eta(int sec) {
if (sec >= 3600) return trf('{0}시간 {1}분', [sec ~/ 3600, (sec % 3600) ~/ 60]);
if (sec >= 60) return trf('{0}분 {1}초', [sec ~/ 60, sec % 60]);
return trf('{0}초', [sec]);
}
@override
Future<void> pause(DownloadTask t) async {
final gid = t.extra['gid'] as String?;
if (gid != null && _daemon != null) {
try {
await _call('aria2.forcePause', [gid]);
} catch (_) {}
}
t
..state = DownloadState.paused
..speed = ''
..eta = '';
}
@override
Future<void> cancel(DownloadTask t) async {
final gid = t.extra['gid'] as String?;
if (gid != null && _daemon != null) {
try {
await _call('aria2.forceRemove', [gid]);
await _call('aria2.removeDownloadResult', [gid]);
} catch (_) {}
}
_tasks.remove(t.id);
t
..state = DownloadState.cancelled
..speed = ''
..eta = '';
await Future<void>.delayed(const Duration(milliseconds: 300)); // 파일 핸들 해제 대기
for (final f in t.files) {
for (final path in [f, '$f.aria2']) {
try {
final file = File(path);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
}
@override
Future<void> shutdown() async {
_poll?.cancel();
_poll = null;
final d = _daemon;
if (d != null) {
try {
await _call('aria2.forceShutdown', []);
await d.exitCode.timeout(const Duration(seconds: 5));
} catch (_) {
d.kill();
}
}
_daemon = null;
_client.close(force: true);
}
}
/// 28 확인 (10/20): 인터넷에 닿는 IPv6 (전역 2000::/3) 가 있는지. ULA (fc00::/7 - Tailscale fd7a:115c:a1e0:: 포함) ·
/// 링크 로컬 (fe80::) · Teredo (2001:0::/32) · 6to4 (2002::/16) 만 있으면 없음
bool hasInternetIpv6(Iterable<InternetAddress> addrs) => addrs.any((a) {
if (a.type != InternetAddressType.IPv6) return false;
final b = a.rawAddress;
if ((b[0] & 0xE0) != 0x20) return false;
if (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0) return false;
if (b[0] == 0x20 && b[1] == 0x02) return false;
return true;
});
/// 인터넷 IPv4 로 보이는 주소가 있는지 (127/8 · 169.254/16 · Tailscale 100.64/10 은 빼고 - 사설 주소는 공유기 뒤라 넣는다)
bool hasInternetIpv4(Iterable<InternetAddress> addrs) => addrs.any((a) {
if (a.type != InternetAddressType.IPv4) return false;
final b = a.rawAddress;
if (b[0] == 127 || (b[0] == 169 && b[1] == 254)) return false;
if (b[0] == 100 && (b[1] & 0xC0) == 64) return false;
return true;
});