WIP 263 다운로드: DNS 서버에 닿지 않아 실패하면 줄에 이유 · [시스템 DNS 로 바꾸기] (검토 대기)

- 관리자 지시 (42 가 찾던 것): DNS 강제 프록시를 거친 yt-dlp · aria2 는 "Tunnel connection failed" 만 남겨
  줄에는 "연결할 수 없습니다" 뿐이라 이유 · 할 일을 몰랐다.
- 실패한 그때 DNS 강제 + "닿지 않음" 상태이고 오류가 연결 · 프록시 실패이면 줄에 "DNS 서버 (…) 에 닿지 않아 받지 못했습니다".
  404 · 로그인 같은 다른 실패는 DNS 탓으로 보지 않는다.
- 아직 DNS 강제면 [시스템 DNS 로 바꾸기] (설정에 저장, 버튼은 사라짐). 다시 받기는 [다시 시도] 로 사용자가 (내 판단: 몰래 다시 받지 않음).
- 번역 en · ja · zh-Hans, 시험 download_error_test (판단 · 줄 · 버튼).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 09:55:14 +09:00
parent f596ef7253
commit a2cb535f1e
7 changed files with 116 additions and 5 deletions

View File

@ -158,6 +158,7 @@
"DNS 서버 ({0}) 에 닿지 않습니다: {1}": "Cannot reach the DNS server ({0}): {1}", "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}": "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}) 에 닿지 않아 {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}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · 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}": "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})", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "Can't reach DNS server ({0}) (since {1})",

View File

@ -158,6 +158,7 @@
"DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}",
"DNS 서버 ({0}) 에 닿지 않아 {1}": "DNS サーバー ({0}) に接続できないため、{1}", "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}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "DNS サーバー ({0}) に届かず、アドレスを見つけられませんでした。インターネット接続か、環境設定 > ネットワーク · DNS のサーバーアドレスを確認してください。",
"DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}",
"DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "DNS サーバー ({0}) に接続できません ({1} から)", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "DNS サーバー ({0}) に接続できません ({1} から)",

View File

@ -158,6 +158,7 @@
"DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}",
"DNS 서버 ({0}) 에 닿지 않아 {1}": "无法连接 DNS 服务器 ({0}),因此{1}", "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}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "无法连接 DNS 服务器({0}),未能找到地址。请检查网络连接,或检查 设置 > 网络 · DNS 中的服务器地址。",
"DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}",
"DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "无法连接 DNS 服务器 ({0}) (自 {1} 起)", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "无法连接 DNS 服务器 ({0}) (自 {1} 起)",

View File

@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import '../services/downloader.dart'; import '../services/downloader.dart';
import 'net_guard.dart';
import 'settings.dart'; import 'settings.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -41,6 +42,21 @@ class DownloadManager extends ChangeNotifier {
/// "완료 시 자동 동영상 추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다. /// "완료 시 자동 동영상 추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다.
Future<void> Function(bool on)? setAutoAdd; Future<void> Function(bool on)? setAutoAdd;
/// 263: DNS 를 시스템 DNS 로 바꾸는 함수 (설정에 저장). 앱이 연결해 준다 - 실패한 줄의 [시스템 DNS 로 바꾸기]
Future<void> 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(); void refresh() => notifyListeners();
@ -235,6 +251,13 @@ class DownloadManager extends ChangeNotifier {
case DownloadState.done: case DownloadState.done:
log?.call(trf('다운로드 완료: {0}{1}', [name, t.totalBytes == null ? '' : ' (${formatBytes(t.totalBytes!)})'])); log?.call(trf('다운로드 완료: {0}{1}', [name, t.totalBytes == null ? '' : ' (${formatBytes(t.totalBytes!)})']));
case DownloadState.failed: 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 ?? ''])); log?.call(trf('다운로드 실패: {0} - {1}', [name, t.error ?? '']));
default: default:
break; break;
@ -294,6 +317,7 @@ class DownloadManager extends ChangeNotifier {
for (final t in list.toList()) { for (final t in list.toList()) {
if (t.state == DownloadState.paused || t.state == DownloadState.failed) { if (t.state == DownloadState.paused || t.state == DownloadState.failed) {
if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가 if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가
t.extra.remove('dnsServer');
t t
..state = DownloadState.queued ..state = DownloadState.queued
..error = null; ..error = null;

View File

@ -304,6 +304,10 @@ Future<void> main(List<String> args) async {
await controller.updateSettings((x) => x.addFinishedDownloads = on); await controller.updateSettings((x) => x.addFinishedDownloads = on);
downloads.refresh(); downloads.refresh();
}; };
downloads.useSystemDns = () async {
await controller.updateSettings((x) => x.dnsMode = 'system');
downloads.refresh();
};
// COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인) // COM 준비 상태 지킴이: 풀려 있으면 (앱 안 브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨는 원인)
// 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다. // 다시 준비하고 작업 기록에 남긴다. 바로 앞의 기록을 보면 무엇을 한 뒤에 풀렸는지 알 수 있다.
Timer.periodic(const Duration(seconds: 3), (_) { Timer.periodic(const Duration(seconds: 3), (_) {
@ -619,6 +623,10 @@ Future<void> runAndroid(String dataDir) async {
await controller.updateSettings((x) => x.addFinishedDownloads = on); await controller.updateSettings((x) => x.addFinishedDownloads = on);
downloads.refresh(); downloads.refresh();
}; };
downloads.useSystemDns = () async {
await controller.updateSettings((x) => x.dnsMode = 'system');
downloads.refresh();
};
downloads.addToEditList = controller.addDownloaded; downloads.addToEditList = controller.addDownloaded;
downloads.log = controller.note; downloads.log = controller.note;
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)

View File

@ -258,12 +258,18 @@ class _DownloadRow extends StatelessWidget {
static final _small = TextButton.styleFrom( static final _small = TextButton.styleFrom(
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)); 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 가 남긴 그대로 - 복사해 물어볼 수 있게) /// 136: 실패 원문 (yt-dlp · aria2 가 남긴 그대로 - 복사해 물어볼 수 있게)
static Future<void> _showDetail(BuildContext context, DownloadTask t) => showDialog<void>( static Future<void> _showDetail(BuildContext context, DownloadTask t) => showDialog<void>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
scrollable: true, scrollable: true,
title: Text(friendlyDownloadError(t.error ?? '')), title: Text(_why(t)),
content: SizedBox( content: SizedBox(
width: 560, width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
@ -311,14 +317,16 @@ class _DownloadRow extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
if (t.state == DownloadState.failed) if (t.state == DownloadState.failed)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text('$label · ${friendlyDownloadError(t.error ?? '')}', Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)),
style: const TextStyle(fontSize: 12, color: JjColors.danger)),
if ((t.error ?? '').trim().isNotEmpty) if ((t.error ?? '').trim().isNotEmpty)
TextButton( TextButton(
style: _small, style: _small,
onPressed: () => _showDetail(context, t), onPressed: () => _showDetail(context, t),
child: Text(tr('자세히')), 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('다시 시도'))), TextButton(style: _small, onPressed: () => d.resume([t]), child: Text(tr('다시 시도'))),
]) ])
else else

View File

@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/download_manager.dart'; import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/i18n_controller.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/app/settings.dart';
import 'package:jj_mkvmaker/core/dns_resolver.dart';
import 'package:jj_mkvmaker/core/download_detect.dart'; import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/services/downloader.dart'; import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart'; import 'package:jj_mkvmaker/ui/downloads_page.dart';
class _Backend implements DownloadBackend { class _Backend implements DownloadBackend {
var starts = 0; 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 @override
DownloadKind get kind => DownloadKind.video; DownloadKind get kind => DownloadKind.video;
@override @override
@ -18,8 +23,7 @@ class _Backend implements DownloadBackend {
starts++; starts++;
t t
..state = DownloadState.failed ..state = DownloadState.failed
..error = 'ERROR: [generic] Unable to download webpage: HTTP Error 404: Not Found ' ..error = error;
'(https://cdn.example.com/v.mp4?sig=SECRET123&expire=1)';
changed(); changed();
} }
@ -93,4 +97,68 @@ void main() {
expect(b.starts, 2, reason: '[다시 시도] 로 다시 받기 시작 (이 가짜 백엔드는 또 실패)'); expect(b.starts, 2, reason: '[다시 시도] 로 다시 받기 시작 (이 가짜 백엔드는 또 실패)');
d.dispose(); 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();
});
});
} }