diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 9828e50..77c9409 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -158,6 +158,7 @@ "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "Cannot reach the DNS server ({0}): {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "Couldn't reach the DNS server ({0}), so {1}", "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "Can't open {1} because the DNS server ({0}) can't be reached.", + "DNS 서버 ({0}) 에 닿지 않아 받지 못했습니다": "Couldn't reach the DNS server ({0}), so the download failed", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Couldn't reach the DNS server ({0}), so the address couldn't be found. Check the internet connection or the server address in Settings > Network · DNS.", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "Could not look up the address because the DNS server ({0}) is unreachable: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "Can't reach DNS server ({0}) (since {1})", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 5ecafd2..0a962cc 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -158,6 +158,7 @@ "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "DNS サーバー ({0}) に接続できないため、{1}", "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "DNS サーバー ({0}) に接続できないため、{1} を開けません。", + "DNS 서버 ({0}) 에 닿지 않아 받지 못했습니다": "DNS サーバー ({0}) に接続できず、ダウンロードできませんでした", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "DNS サーバー ({0}) に届かず、アドレスを見つけられませんでした。インターネット接続か、環境設定 > ネットワーク · DNS のサーバーアドレスを確認してください。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "DNS サーバー ({0}) に接続できません ({1} から)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 103d87a..c238f98 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -158,6 +158,7 @@ "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "无法连接 DNS 服务器 ({0}),因此{1}", "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "无法连接 DNS 服务器 ({0}),因此无法打开 {1}。", + "DNS 서버 ({0}) 에 닿지 않아 받지 못했습니다": "无法连接 DNS 服务器 ({0}),下载失败", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "无法连接 DNS 服务器({0}),未能找到地址。请检查网络连接,或检查 设置 > 网络 · DNS 中的服务器地址。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "无法连接 DNS 服务器 ({0}) (自 {1} 起)", diff --git a/lib/app/download_manager.dart b/lib/app/download_manager.dart index b163631..e1c526c 100644 --- a/lib/app/download_manager.dart +++ b/lib/app/download_manager.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import '../core/download_detect.dart'; import '../core/playlist.dart'; import '../services/downloader.dart'; +import 'net_guard.dart'; import 'settings.dart'; import '../l10n/tr.dart'; @@ -41,6 +42,21 @@ class DownloadManager extends ChangeNotifier { /// "완료 시 자동 동영상 추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다. Future Function(bool on)? setAutoAdd; + /// 263: DNS 를 시스템 DNS 로 바꾸는 함수 (설정에 저장). 앱이 연결해 준다 - 실패한 줄의 [시스템 DNS 로 바꾸기] + Future Function()? useSystemDns; + + /// 263: 이 실패가 정한 DNS 서버에 닿지 않아서인지 - 그 서버 (아니면 null). + /// DNS 강제가 켜져 있고 "닿지 않음" 상태이며, 오류가 연결 · 프록시 실패일 때만 (404 · 로그인 같은 다른 실패는 DNS 탓으로 보지 않는다) + static String? dnsCause(DownloadTask t, {NetGuard? guard}) { + final g = guard ?? NetGuard.instance; + final f = g.failure.value; + if (f == null || !g.config().forced) return null; + final err = t.error ?? ''; + final e = err.toLowerCase(); + if (!e.contains('proxy') && !e.contains('tunnel') && friendlyDownloadError(err) != tr('연결할 수 없습니다')) return null; + return f.server; + } + /// 설정이 바뀌었을 때 화면 갱신 void refresh() => notifyListeners(); @@ -235,6 +251,13 @@ class DownloadManager extends ChangeNotifier { case DownloadState.done: log?.call(trf('다운로드 완료: {0}{1}', [name, t.totalBytes == null ? '' : ' (${formatBytes(t.totalBytes!)})'])); case DownloadState.failed: + // 263: 실패한 그때 DNS 서버에 닿지 않았으면 줄에 이유와 [시스템 DNS 로 바꾸기] (나중에 서버가 다시 답해도 이 줄의 이유는 남는다) + final dns = dnsCause(t); + if (dns == null) { + t.extra.remove('dnsServer'); + } else { + t.extra['dnsServer'] = dns; + } log?.call(trf('다운로드 실패: {0} - {1}', [name, t.error ?? ''])); default: break; @@ -294,6 +317,7 @@ class DownloadManager extends ChangeNotifier { for (final t in list.toList()) { if (t.state == DownloadState.paused || t.state == DownloadState.failed) { if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가 + t.extra.remove('dnsServer'); t ..state = DownloadState.queued ..error = null; diff --git a/lib/main.dart b/lib/main.dart index 38f4fd0..9e1b605 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -304,6 +304,10 @@ Future main(List args) async { await controller.updateSettings((x) => x.addFinishedDownloads = on); downloads.refresh(); }; + downloads.useSystemDns = () async { + await controller.updateSettings((x) => x.dnsMode = 'system'); + downloads.refresh(); + }; // COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인) // 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다. Timer.periodic(const Duration(seconds: 3), (_) { @@ -619,6 +623,10 @@ Future runAndroid(String dataDir) async { await controller.updateSettings((x) => x.addFinishedDownloads = on); downloads.refresh(); }; + downloads.useSystemDns = () async { + await controller.updateSettings((x) => x.dnsMode = 'system'); + downloads.refresh(); + }; downloads.addToEditList = controller.addDownloaded; downloads.log = controller.note; // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 4b8d885..4b7ad0b 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -258,12 +258,18 @@ class _DownloadRow extends StatelessWidget { static final _small = TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)); + /// 실패 이유 (사람 말). 263: DNS 서버에 닿지 않아 실패했으면 그 이유 (yt-dlp 원문은 "프록시 터널 실패" 뿐이라 알 수 없었다) + static String _why(DownloadTask t) => switch (t.extra['dnsServer']) { + final String s => trf('DNS 서버 ({0}) 에 닿지 않아 받지 못했습니다', [s]), + _ => friendlyDownloadError(t.error ?? ''), + }; + /// 136: 실패 원문 (yt-dlp · aria2 가 남긴 그대로 - 복사해 물어볼 수 있게) static Future _showDetail(BuildContext context, DownloadTask t) => showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, - title: Text(friendlyDownloadError(t.error ?? '')), + title: Text(_why(t)), content: SizedBox( width: 560, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -311,14 +317,16 @@ class _DownloadRow extends StatelessWidget { const SizedBox(height: 4), if (t.state == DownloadState.failed) Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ - Text('$label · ${friendlyDownloadError(t.error ?? '')}', - style: const TextStyle(fontSize: 12, color: JjColors.danger)), + Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)), if ((t.error ?? '').trim().isNotEmpty) TextButton( style: _small, onPressed: () => _showDetail(context, t), child: Text(tr('자세히')), ), + // 263: 아직 DNS 강제면 [시스템 DNS 로 바꾸기] (바꾼 뒤 [다시 시도] 는 사용자가 - 몰래 다시 받지 않음) + if (t.extra['dnsServer'] != null && d.useSystemDns != null && d.settings().dnsMode != 'system') + TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))), TextButton(style: _small, onPressed: () => d.resume([t]), child: Text(tr('다시 시도'))), ]) else diff --git a/test/download_error_test.dart b/test/download_error_test.dart index df76e6f..a5eb22f 100644 --- a/test/download_error_test.dart +++ b/test/download_error_test.dart @@ -2,13 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/download_manager.dart'; import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/dns_resolver.dart'; import 'package:jj_mkvmaker/core/download_detect.dart'; import 'package:jj_mkvmaker/services/downloader.dart'; import 'package:jj_mkvmaker/ui/downloads_page.dart'; class _Backend implements DownloadBackend { var starts = 0; + final String error; + _Backend([this.error = 'ERROR: [generic] Unable to download webpage: HTTP Error 404: Not Found ' + '(https://cdn.example.com/v.mp4?sig=SECRET123&expire=1)']); @override DownloadKind get kind => DownloadKind.video; @override @@ -18,8 +23,7 @@ class _Backend implements DownloadBackend { starts++; t ..state = DownloadState.failed - ..error = 'ERROR: [generic] Unable to download webpage: HTTP Error 404: Not Found ' - '(https://cdn.example.com/v.mp4?sig=SECRET123&expire=1)'; + ..error = error; changed(); } @@ -93,4 +97,68 @@ void main() { expect(b.starts, 2, reason: '[다시 시도] 로 다시 받기 시작 (이 가짜 백엔드는 또 실패)'); d.dispose(); }); + + // 263: DNS 강제 프록시를 거친 yt-dlp 는 "Tunnel connection failed" 만 남겨, 이유를 몰랐다 (42 · a4) + group('263 DNS 서버에 닿지 않아 실패한 다운로드', () { + final g = NetGuard.instance; + late DnsConfig Function() before; + setUp(() { + before = g.config; + g.config = () => const DnsConfig(mode: 'custom', primary: '192.0.2.1'); + }); + tearDown(() { + g.config = before; + g.failure.value = null; + }); + + DownloadTask failed(String error) => DownloadTask(id: '1', kind: DownloadKind.video, source: 'https://x.example/v', dir: '.') + ..state = DownloadState.failed + ..error = error; + const tunnel = "ERROR: [generic] Unable to download webpage: ('Unable to connect to proxy', OSError('Tunnel connection failed: 502 Bad Gateway'))"; + + test('연결 · 프록시 실패이고 지금 닿지 않음일 때만 DNS 탓', () { + expect(DownloadManager.dnsCause(failed(tunnel)), isNull, reason: '닿지 않음 상태가 아니면 아님'); + g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); + expect(DownloadManager.dnsCause(failed(tunnel)), '192.0.2.1'); + expect(DownloadManager.dnsCause(failed('Network problem has occurred. cause:Connection refused (errorCode=6)')), '192.0.2.1'); + expect(DownloadManager.dnsCause(failed('ERROR: [generic] x: HTTP Error 404: Not Found')), isNull, reason: '404 는 DNS 탓이 아님'); + g.config = () => const DnsConfig.system(); + expect(DownloadManager.dnsCause(failed(tunnel)), isNull, reason: '시스템 DNS 면 강제 프록시가 없다'); + }); + + testWidgets('줄에 이유 · [시스템 DNS 로 바꾸기] - 누르면 설정이 시스템 DNS 가 되고 버튼은 사라진다 (다시 받기는 사용자가)', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); + final s = AppSettings(); + final b = _Backend(tunnel); + final d = DownloadManager(backends: [b], settings: () => s, readClipboard: () async => null); + d.useSystemDns = () async { + s.dnsMode = 'system'; + d.refresh(); + }; + final t = d.addPage('https://cdn.example.com/page')!..title = '영상'; + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + for (var i = 0; i < 20 && t.state != DownloadState.failed; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pump(); + expect(find.textContaining('DNS 서버 (192.0.2.1) 에 닿지 않아 받지 못했습니다'), findsOneWidget); + expect(find.text('시스템 DNS 로 바꾸기'), findsOneWidget); + + await tester.tap(find.text('시스템 DNS 로 바꾸기')); + await tester.pump(); + expect(s.dnsMode, 'system'); + expect(find.text('시스템 DNS 로 바꾸기'), findsNothing); + expect(b.starts, 1, reason: '몰래 다시 받지 않는다'); + + await tester.tap(find.text('다시 시도')); + for (var i = 0; i < 20 && b.starts < 2; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(b.starts, 2); + d.dispose(); + }); + }); }