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 = {}; /// 추가 환경 변수 · 인수 (Android: 라이브러리 위치 · 인증서 파일) final Map environment; final List 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)?> expandPlaylist(String url) async => null; static String _randomToken() { final r = math.Random.secure(); return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join(); } Future _ensureDaemon() async { if (_daemon != null) return; // 번호를 넘겨 aria2 가 직접 여는 방식이라, 닫고 넘기는 사이에 다른 프로그램이 가져가면 aria2 가 끝난다 - 그때는 새 번호로 몇 번 다시 for (var attempt = 0;; attempt++) { try { return await _startDaemon(); } on SocketException { if (attempt >= 2) rethrow; } } } Future _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(); _daemon!.stderr.drain(); // 이 프로세스가 끝나면 (다시 띄운 새 것은 건드리지 않게) 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.delayed(const Duration(milliseconds: 100)); } } _daemon?.kill(); _daemon = null; throw SocketException(tr('aria2 RPC 에 연결할 수 없습니다.')); } /// 이 aria2c 가 자체 DNS (c-ares, --async-dns-server) 를 갖췄는지 (없는 옵션을 주면 시작하지 못한다) Future _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 _call(String method, List 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 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 _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 _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> _stallReasons() async => [ if (!await _internetIpv4()) 'ipv6only', 'firewall', if (NetGuard.instance.forcing) 'dns', ]; Future _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 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 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.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 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 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 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; });