WIP 마그넷: 인터넷 IPv6 가 없으면 (Tailscale ULA 뿐) aria2 의 IPv6 끔 - 시작되지 않던 것 (28 확인) · 정한 시간 (처음 60초) 동안 메타데이터 · 피어가 없으면 줄에 이유 후보 · [다시 시도] · 실제 마그넷 받기 통합 시험 (JJ_REAL_MAGNET=1) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>next5
parent
f13de6b1d8
commit
6b60f348e2
|
|
@ -166,6 +166,7 @@
|
|||
"Chrome 엔진을 시작하지 못했습니다": "Couldn't start the Chrome engine",
|
||||
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}",
|
||||
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Couldn't reopen the Chrome engine's connection path. Restart the app.",
|
||||
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "Forced DNS (Settings > Network · DNS)",
|
||||
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS-forcing proxy started: 127.0.0.1:{0} ({1})",
|
||||
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS proxy: rejected a connection without the secret ({0})",
|
||||
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS proxy: rejected a connection with a wrong secret ({0})",
|
||||
|
|
@ -1137,6 +1138,7 @@
|
|||
"링크 {0}개는 세지 않음": "{0} links not counted",
|
||||
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} links / junctions (not followed or counted)",
|
||||
"링크 · junction 은 넣지 않습니다": "Links and junctions are not included",
|
||||
"마그넷 · 토렌트가 시작되지 않으면 알리기": "Notify when a magnet / torrent doesn't start",
|
||||
"마스터 · 최상 비밀번호": "Master and super passwords",
|
||||
"마스터 비밀번호": "Master password",
|
||||
"마스터 비밀번호 넣기": "Enter master password",
|
||||
|
|
@ -1196,6 +1198,7 @@
|
|||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "Paused (this session) · press the start button to sync again",
|
||||
"멈춤 · {0}": "Stopped · {0}",
|
||||
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "Android stopped the AI engine because memory ran out. Close other apps or set the upscaling memory in Settings to \"Low\"",
|
||||
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "If no metadata or peers arrive within this time, the row shows possible reasons and [Retry]",
|
||||
"모니터링": "Monitor",
|
||||
"모니터링의 [실행]": "[Run] in Monitoring",
|
||||
"모델 (선택)": "Model (optional)",
|
||||
|
|
@ -1343,6 +1346,7 @@
|
|||
"밤 0~6시": "Night 0-6",
|
||||
"방법: {0}": "Method: {0}",
|
||||
"방식": "Type",
|
||||
"방화벽 · 공유기가 토렌트를 막음": "A firewall or router is blocking BitTorrent",
|
||||
"방화벽 허용 앱 열기": "Open allowed apps",
|
||||
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "Allow this app in the firewall: Control Panel > Windows Defender Firewall > Allow an app or feature through Windows Defender Firewall > [Change settings] > check \"Private\" for JJ_MKVMaker > [OK].",
|
||||
"배율": "Scale",
|
||||
|
|
@ -1730,6 +1734,7 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "I understand - run anyway (the delete list may differ from what actually happens)",
|
||||
"알리지 않음": "Don't notify",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
|
||||
"암호로 묶기": "Make password ZIP",
|
||||
|
|
@ -2127,6 +2132,7 @@
|
|||
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "Not used this time (no translation)",
|
||||
"이어 받기": "Resume",
|
||||
"이유 후보: {0}": "Possible reasons: {0}",
|
||||
"이전": "Previous",
|
||||
"이전 (P)": "Previous (P)",
|
||||
"이전 설정을 되살렸습니다": "Previous settings restored",
|
||||
|
|
@ -2137,6 +2143,7 @@
|
|||
"인증서 확인 안 함": "Don't verify certificate",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "Certificate: a publicly trusted one connects directly; a self-signed one shows its fingerprint and asks at [Test connection]",
|
||||
"인코딩": "Encoding",
|
||||
"인터넷 IPv4 가 없음 (IPv6 만)": "No internet IPv4 (IPv6 only)",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Check your internet connection or the server address in Settings > Network · DNS.",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
|
||||
|
|
@ -2588,6 +2595,7 @@
|
|||
"토": "Sat",
|
||||
"토렌트 · 마그넷 다운로드": "Torrent · magnet download",
|
||||
"통과": "Passed",
|
||||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "Can't reach trackers or peers ({0} s)",
|
||||
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
|
||||
"트랙 {0}": "Track {0}",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
"Chrome 엔진을 시작하지 못했습니다": "Chrome エンジンを起動できませんでした",
|
||||
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}",
|
||||
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Chrome エンジンの接続経路を開き直せません。アプリを再起動してください。",
|
||||
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "DNS 強制 (設定 > ネットワーク・DNS)",
|
||||
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 強制プロキシ開始: 127.0.0.1:{0} ({1})",
|
||||
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値のない接続を拒否 ({0})",
|
||||
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値が違う接続を拒否 ({0})",
|
||||
|
|
@ -1137,6 +1138,7 @@
|
|||
"링크 {0}개는 세지 않음": "リンク {0} 件は数えず",
|
||||
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "リンク・ジャンクション {0} 個 (たどらず数えません)",
|
||||
"링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません",
|
||||
"마그넷 · 토렌트가 시작되지 않으면 알리기": "マグネット・トレントが始まらないときに知らせる",
|
||||
"마스터 · 최상 비밀번호": "マスター / 最上位パスワード",
|
||||
"마스터 비밀번호": "マスターパスワード",
|
||||
"마스터 비밀번호 넣기": "マスターパスワードを入力",
|
||||
|
|
@ -1196,6 +1198,7 @@
|
|||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "停止中 (今回の実行中) · 開始ボタンで再び同期します",
|
||||
"멈춤 · {0}": "停止 · {0}",
|
||||
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "メモリ不足のため Android が AI エンジンを終了しました。ほかのアプリを閉じるか、設定の解像度アップのメモリを「少なめ」にしてください",
|
||||
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "この時間内にメタデータやピアを受け取れないと、その行に考えられる理由と [再試行] を表示します",
|
||||
"모니터링": "モニタリング",
|
||||
"모니터링의 [실행]": "モニタリングの [実行]",
|
||||
"모델 (선택)": "モデル (任意)",
|
||||
|
|
@ -1343,6 +1346,7 @@
|
|||
"밤 0~6시": "夜 0~6 時",
|
||||
"방법: {0}": "方法: {0}",
|
||||
"방식": "方式",
|
||||
"방화벽 · 공유기가 토렌트를 막음": "ファイアウォール・ルーターがトレントを遮断",
|
||||
"방화벽 허용 앱 열기": "許可されたアプリを開く",
|
||||
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "ファイアウォールでこのアプリを許可します: コントロール パネル > Windows Defender ファイアウォール > Windows Defender ファイアウォールを介したアプリまたは機能を許可 > [設定の変更] > JJ_MKVMaker の「プライベート」にチェック > [OK]。",
|
||||
"배율": "倍率",
|
||||
|
|
@ -1730,6 +1734,7 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "理解した上で実行します (削除リストが実際と異なる場合があります)",
|
||||
"알리지 않음": "知らせない",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
|
||||
"암호로 묶기": "パスワード付きでまとめる",
|
||||
|
|
@ -2127,6 +2132,7 @@
|
|||
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "今回は使用しません (翻訳なし)",
|
||||
"이어 받기": "続きをダウンロード",
|
||||
"이유 후보: {0}": "考えられる理由: {0}",
|
||||
"이전": "前へ",
|
||||
"이전 (P)": "前へ(P)",
|
||||
"이전 설정을 되살렸습니다": "以前の設定を復元しました",
|
||||
|
|
@ -2137,6 +2143,7 @@
|
|||
"인증서 확인 안 함": "証明書を検証しない",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "証明書: 公的な証明書ならそのまま接続し、自己署名なら [接続確認] で指紋を表示して確認します",
|
||||
"인코딩": "エンコード",
|
||||
"인터넷 IPv4 가 없음 (IPv6 만)": "インターネット IPv4 がない (IPv6 のみ)",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "インターネット接続、または 設定 > ネットワーク · DNS のサーバーアドレスを確認してください。",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
|
||||
|
|
@ -2588,6 +2595,7 @@
|
|||
"토": "土",
|
||||
"토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード",
|
||||
"통과": "合格",
|
||||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "トラッカー・ピアに届きません ({0} 秒)",
|
||||
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
|
||||
"트랙 {0}": "トラック {0}",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
"Chrome 엔진을 시작하지 못했습니다": "无法启动 Chrome 引擎",
|
||||
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}",
|
||||
"Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "无法重新打开 Chrome 引擎的连接通道。请重新启动应用。",
|
||||
"DNS 강제 (환경 설정 > 네트워크 · DNS)": "强制 DNS (设置 > 网络 · DNS)",
|
||||
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 强制代理已启动: 127.0.0.1:{0} ({1})",
|
||||
"DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 强制代理:已拒绝没有密钥的连接({0})",
|
||||
"DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 强制代理:已拒绝密钥错误的连接({0})",
|
||||
|
|
@ -1137,6 +1138,7 @@
|
|||
"링크 {0}개는 세지 않음": "{0} 个链接未计入",
|
||||
"링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} 个链接 / 联接 (不跟随、不计入)",
|
||||
"링크 · junction 은 넣지 않습니다": "不包含链接和 junction",
|
||||
"마그넷 · 토렌트가 시작되지 않으면 알리기": "磁力链接 · 种子无法开始时提醒",
|
||||
"마스터 · 최상 비밀번호": "主密码和最高密码",
|
||||
"마스터 비밀번호": "主密码",
|
||||
"마스터 비밀번호 넣기": "输入主密码",
|
||||
|
|
@ -1196,6 +1198,7 @@
|
|||
"멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다": "已暂停 (本次运行期间) · 按开始按钮重新同步",
|
||||
"멈춤 · {0}": "已停止 · {0}",
|
||||
"메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 \"적게\" 로 해 보세요": "内存不足,Android 结束了 AI 引擎。请关闭其他应用,或在设置中将提高分辨率的内存设为“少”",
|
||||
"메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다": "如果在此时间内未获得元数据或对等方,该行会显示可能原因和 [重试]",
|
||||
"모니터링": "监控",
|
||||
"모니터링의 [실행]": "监视中的 [执行]",
|
||||
"모델 (선택)": "模型 (可选)",
|
||||
|
|
@ -1343,6 +1346,7 @@
|
|||
"밤 0~6시": "夜间 0~6 点",
|
||||
"방법: {0}": "方式: {0}",
|
||||
"방식": "方式",
|
||||
"방화벽 · 공유기가 토렌트를 막음": "防火墙 · 路由器阻止了 BitTorrent",
|
||||
"방화벽 허용 앱 열기": "打开允许的应用",
|
||||
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "在防火墙中允许此应用:控制面板 > Windows Defender 防火墙 > 允许应用或功能通过 Windows Defender 防火墙 > [更改设置] > 勾选 JJ_MKVMaker 的“专用” > [确定]。",
|
||||
"배율": "倍率",
|
||||
|
|
@ -1730,6 +1734,7 @@
|
|||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||
"알고 실행합니다 (지울 목록이 실제와 다를 수 있음)": "我已了解并执行 (删除列表可能与实际不同)",
|
||||
"알리지 않음": "不提醒",
|
||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
|
||||
"암호로 묶기": "加密压缩",
|
||||
|
|
@ -2127,6 +2132,7 @@
|
|||
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
|
||||
"이번에는 쓰지 않음 (번역 없음)": "本次不使用 (无需翻译)",
|
||||
"이어 받기": "继续下载",
|
||||
"이유 후보: {0}": "可能原因: {0}",
|
||||
"이전": "上一个",
|
||||
"이전 (P)": "上一个(P)",
|
||||
"이전 설정을 되살렸습니다": "已恢复之前的设置",
|
||||
|
|
@ -2137,6 +2143,7 @@
|
|||
"인증서 확인 안 함": "不验证证书",
|
||||
"인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다": "证书:公共信任的证书直接连接;自签名证书会在 [测试连接] 时显示指纹并询问",
|
||||
"인코딩": "编码",
|
||||
"인터넷 IPv4 가 없음 (IPv6 만)": "没有互联网 IPv4 (仅 IPv6)",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}",
|
||||
"인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "请检查网络连接或 设置 > 网络 · DNS 中的服务器地址。",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
|
||||
|
|
@ -2588,6 +2595,7 @@
|
|||
"토": "六",
|
||||
"토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载",
|
||||
"통과": "通过",
|
||||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "无法连接 tracker 或对等方 ({0} 秒)",
|
||||
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
|
||||
"트랙 {0}": "轨道 {0}",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/net_guard.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart' show DownloadKind;
|
||||
import 'package:jj_mkvmaker/platform/windows/aria2_backend.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 사용자 질문 (10/20) "마그넷 테스트 된 건가" - 지금까지는 단위 시험뿐이었다. 실제 마그넷을 앱의 다운로드 관리자 (aria2c) 로 받는다.
|
||||
/// 공개된 합법 배포 파일: Debian 공식 netinst ISO 의 토렌트 (cdimage.debian.org). 정보 해시는 공식 .torrent 에서 계산해
|
||||
/// 마그넷을 만들고 (판이 바뀌어도 맞게), 끝나면 공식 SHA256SUMS 와 맞춘다.
|
||||
/// - DNS 시스템: 끝까지 받기 · 진행률 · 속도 · 일시 정지 / 재개 · SHA256
|
||||
/// - DNS 강제 (8.8.8.8 · 8.8.4.4, 앱 안 프록시): 받기 시작 · 진행률 · 일시 정지 / 재개 · 취소 (받던 파일 지움)
|
||||
/// 인터넷 · 디스크 (약 800MB) 를 쓰므로 JJ_REAL_MAGNET=1 일 때만:
|
||||
/// flutter test integration_test\magnet_real_test.dart -d windows (PowerShell: $env:JJ_REAL_MAGNET='1')
|
||||
/// 받은 파일은 시험 폴더 (%TEMP%\jj_magnet_*) 에 두고 끝나면 지운다.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const base = 'https://cdimage.debian.org/debian-cd/current/amd64/bt-cd/';
|
||||
|
||||
for (final forced in [false, true]) {
|
||||
testWidgets('마그넷 실제 받기 (DNS ${forced ? '강제 8.8.8.8' : '시스템'})', (tester) async {
|
||||
if (Platform.environment['JJ_REAL_MAGNET'] != '1') return markTestSkipped('JJ_REAL_MAGNET=1 일 때만 (인터넷 · 약 800MB)');
|
||||
final aria2c = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'aria2c.exe');
|
||||
expect(File(aria2c).existsSync(), isTrue, reason: '앱에 든 aria2c (tool\\fetch_tools.ps1)');
|
||||
final dir = testTempDir('jj_magnet_');
|
||||
void log(String s) => debugPrint('MAGNET ${forced ? 'DNS강제' : 'DNS시스템'} ${DateTime.now().toIso8601String().substring(11, 19)} $s');
|
||||
|
||||
// 공식 토렌트 · SHA256 (판 이름은 목록에서)
|
||||
late Uint8List torrent;
|
||||
late String isoName, want;
|
||||
await tester.runAsync(() async {
|
||||
final http = HttpClient();
|
||||
Future<List<int>> get(String url) async {
|
||||
final res = await (await http.getUrl(Uri.parse(url))).close();
|
||||
expect(res.statusCode, 200, reason: url);
|
||||
return [for (final c in await res.toList()) ...c];
|
||||
}
|
||||
|
||||
final index = utf8.decode(await get(base));
|
||||
final name = RegExp(r'href="(debian-[0-9.]+-amd64-netinst\.iso)\.torrent"').firstMatch(index)![1]!;
|
||||
isoName = name;
|
||||
torrent = Uint8List.fromList(await get('$base$name.torrent'));
|
||||
final sums = utf8.decode(await get('${base}SHA256SUMS'));
|
||||
want = RegExp('^([0-9a-f]{64})\\s+${RegExp.escape(name)}\$', multiLine: true).firstMatch(sums)![1]!;
|
||||
http.close();
|
||||
});
|
||||
final (infoHash, announce) = _torrentInfo(torrent);
|
||||
final magnet = 'magnet:?xt=urn:btih:$infoHash&dn=${Uri.encodeComponent(isoName)}&tr=${Uri.encodeComponent(announce)}';
|
||||
log('$isoName · $infoHash · 공식 SHA256 $want');
|
||||
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..downloadRoot = dir.path
|
||||
..dnsMode = forced ? 'custom' : 'system'
|
||||
..dnsPrimary = '8.8.8.8'
|
||||
..dnsSecondary = '8.8.4.4';
|
||||
final g = NetGuard.instance
|
||||
..config = (() => c.settings.dnsConfig)
|
||||
..log = log;
|
||||
await tester.runAsync(g.ensure);
|
||||
addTearDown(() async {
|
||||
c.settings.dnsMode = 'system';
|
||||
await g.ensure();
|
||||
});
|
||||
final d = DownloadManager(backends: [Aria2Backend(aria2c: aria2c)], settings: () => c.settings);
|
||||
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||
|
||||
Future<void> wait(int ms) async {
|
||||
for (var i = 0; i < ms ~/ 250; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> until(bool Function() ok, {required Duration within}) async {
|
||||
final end = DateTime.now().add(within);
|
||||
while (!ok() && DateTime.now().isBefore(end)) {
|
||||
await wait(1000);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
// 받기 시작 (메타데이터 → 실제 받기) · 진행률 · 속도
|
||||
final t = d.add(magnet);
|
||||
expect(t, isNotNull);
|
||||
expect(t!.kind, DownloadKind.torrent);
|
||||
final speeds = <String>{};
|
||||
final started = await until(() {
|
||||
if (t.speed.isNotEmpty) speeds.add(t.speed);
|
||||
return (t.receivedBytes ?? 0) >= 8 << 20 || t.state == DownloadState.failed;
|
||||
}, within: const Duration(minutes: 5));
|
||||
log('시작: ${t.state} ${t.receivedBytes}/${t.totalBytes} 속도 $speeds 오류 ${t.error}');
|
||||
expect(started && t.state == DownloadState.downloading, isTrue, reason: '8MB 를 5분 안에 받지 못함: ${t.state} ${t.error}');
|
||||
expect(t.title, isoName, reason: '메타데이터를 받으면 이름이 바뀜');
|
||||
expect(t.progress, greaterThan(0));
|
||||
expect(t.totalBytes, greaterThan(100 << 20));
|
||||
expect(speeds, isNotEmpty, reason: '속도가 보임');
|
||||
expect(find.text(isoName), findsWidgets, reason: '다운로드 화면 줄');
|
||||
|
||||
// 일시 정지: 받은 크기가 늘지 않는다
|
||||
await tester.runAsync(() => d.pause([t]));
|
||||
await wait(3000);
|
||||
final paused = t.receivedBytes ?? 0;
|
||||
final onDisk = t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b);
|
||||
await wait(5000);
|
||||
log('일시 정지: ${t.state} $paused');
|
||||
expect(t.state, DownloadState.paused);
|
||||
expect(t.receivedBytes ?? 0, paused);
|
||||
expect(t.files.where((f) => File(f).existsSync()).map((f) => File(f).lengthSync()).fold(0, (a, b) => a + b), onDisk,
|
||||
reason: '멈춘 동안 파일이 커지지 않음');
|
||||
|
||||
// 재개: 이어서 받는다 (처음부터가 아니라)
|
||||
await tester.runAsync(() => d.resume([t]));
|
||||
final resumed = await until(() => (t.receivedBytes ?? 0) >= paused + (4 << 20), within: const Duration(minutes: 3));
|
||||
log('재개: ${t.state} ${t.receivedBytes}');
|
||||
expect(resumed, isTrue, reason: '재개 뒤 4MB 더 받지 못함');
|
||||
|
||||
if (!forced) {
|
||||
// 끝까지 · 공식 SHA256
|
||||
final done = await until(() => t.state == DownloadState.done || t.state == DownloadState.failed,
|
||||
within: const Duration(minutes: 30));
|
||||
log('끝: ${t.state} ${t.files}');
|
||||
expect(done && t.state == DownloadState.done, isTrue, reason: '${t.state} ${t.error}');
|
||||
final iso = t.files.firstWhere((f) => f.endsWith(isoName));
|
||||
final got = await tester.runAsync(() async => '${await sha256.bind(File(iso).openRead()).first}');
|
||||
log('SHA256 $got');
|
||||
expect(got, want, reason: '공식 SHA256SUMS 와 같음');
|
||||
} else {
|
||||
// 취소: 받던 파일 (와 .aria2) 이 지워진다
|
||||
final files = [...t.files];
|
||||
await tester.runAsync(() => d.cancel([t]));
|
||||
await wait(2000);
|
||||
log('취소: ${t.state} 남은 파일 ${files.where((f) => File(f).existsSync() || File('$f.aria2').existsSync()).toList()}');
|
||||
expect(t.state, DownloadState.cancelled);
|
||||
for (final f in files) {
|
||||
expect(File(f).existsSync() || File('$f.aria2').existsSync(), isFalse, reason: '$f 지워짐');
|
||||
}
|
||||
}
|
||||
await tester.runAsync(d.shutdown);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await wait(500);
|
||||
}, timeout: const Timeout(Duration(minutes: 45)));
|
||||
}
|
||||
}
|
||||
|
||||
/// .torrent (bencode) 에서 정보 해시 (info 사전 바이트의 SHA-1) 와 첫 tracker
|
||||
(String, String) _torrentInfo(Uint8List b) {
|
||||
var i = 0;
|
||||
String str() {
|
||||
final colon = b.indexOf(58, i); // ':'
|
||||
final n = int.parse(ascii.decode(b.sublist(i, colon)));
|
||||
final s = latin1.decode(b.sublist(colon + 1, colon + 1 + n));
|
||||
i = colon + 1 + n;
|
||||
return s;
|
||||
}
|
||||
|
||||
void skip() {
|
||||
final c = b[i];
|
||||
if (c == 0x69) {
|
||||
i = b.indexOf(0x65, i) + 1; // i…e
|
||||
} else if (c == 0x6c || c == 0x64) {
|
||||
i++;
|
||||
while (b[i] != 0x65) {
|
||||
if (c == 0x64) str();
|
||||
skip();
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
str();
|
||||
}
|
||||
}
|
||||
|
||||
String? hash, announce;
|
||||
i = 1; // 맨 위 사전 'd'
|
||||
while (b[i] != 0x65) {
|
||||
final key = str();
|
||||
final start = i;
|
||||
if (key == 'announce') {
|
||||
announce = utf8.decode(latin1.encode(str()));
|
||||
continue;
|
||||
}
|
||||
skip();
|
||||
if (key == 'info') hash = '${sha1.convert(b.sublist(start, i))}';
|
||||
}
|
||||
return (hash!, announce ?? '');
|
||||
}
|
||||
|
|
@ -326,6 +326,21 @@ class DownloadManager extends ChangeNotifier {
|
|||
_changed();
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 (받은 것 없음) 를 처음부터 다시 - 지금 것을 지우고 새로 넣는다
|
||||
Future<void> retryStalled(DownloadTask t) async {
|
||||
await backends[t.kind]!.cancel(t);
|
||||
t.extra
|
||||
..remove('gid')
|
||||
..remove('stallSince')
|
||||
..remove('stalled')
|
||||
..remove('stallReasons');
|
||||
t.files.clear();
|
||||
t
|
||||
..state = DownloadState.queued
|
||||
..error = null;
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김)
|
||||
Future<void> cancel(Iterable<DownloadTask> list) async {
|
||||
for (final t in list.toList()) {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,9 @@ class AppSettings {
|
|||
/// 다운로드가 끝난 동영상을 편집 목록(MKV 만들기)에 자동으로 추가
|
||||
bool addFinishedDownloads = true;
|
||||
|
||||
/// 마그넷 · 토렌트가 이 초 동안 메타데이터 · 피어를 못 받으면 그 줄에 이유 후보 · [다시 시도] (0 = 알리지 않음, 관리자 지시 10/20)
|
||||
int torrentStallSeconds = 60;
|
||||
|
||||
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
|
||||
String openSubtitlesKey = '';
|
||||
String openSubtitlesUser = '';
|
||||
|
|
@ -752,6 +755,7 @@ class AppSettings {
|
|||
'showHotkey': showHotkey,
|
||||
'askAiOptions': askAiOptions,
|
||||
'addFinishedDownloads': addFinishedDownloads,
|
||||
'torrentStallSeconds': torrentStallSeconds,
|
||||
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
|
||||
'openSubtitlesSaved': {
|
||||
'key': openSubtitlesKey.isNotEmpty,
|
||||
|
|
@ -972,6 +976,7 @@ class AppSettings {
|
|||
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
|
||||
..askAiOptions = j['askAiOptions'] as bool? ?? true
|
||||
..addFinishedDownloads = j['addFinishedDownloads'] as bool? ?? true
|
||||
..torrentStallSeconds = const [0, 30, 60, 120, 300].contains(j['torrentStallSeconds']) ? j['torrentStallSeconds'] as int : 60
|
||||
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
|
||||
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
|
||||
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ class AndroidDownloadTools {
|
|||
}),
|
||||
_Deferred(DownloadKind.torrent, () async {
|
||||
final t = await ready();
|
||||
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}']);
|
||||
return Aria2Backend(aria2c: t.aria2c, environment: t.environment, extraArgs: ['--ca-certificate=${t.cert}'],
|
||||
stallAfter: () => Duration(seconds: s().torrentStallSeconds));
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ class Aria2Backend implements DownloadBackend {
|
|||
final Map<String, String> environment;
|
||||
final List<String> extraArgs;
|
||||
|
||||
Aria2Backend({String? aria2c, this.environment = const {}, this.extraArgs = const []})
|
||||
: aria2c = aria2c ?? locateTool('aria2c');
|
||||
/// 마그넷 · 토렌트가 이 시간 동안 메타데이터 · 피어를 못 받으면 그 줄에 알린다 (환경 설정, 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;
|
||||
|
|
@ -69,6 +73,9 @@ class Aria2Backend implements DownloadBackend {
|
|||
'--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);
|
||||
|
|
@ -146,6 +153,32 @@ class Aria2Backend implements DownloadBackend {
|
|||
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?;
|
||||
|
|
@ -154,7 +187,7 @@ class Aria2Backend implements DownloadBackend {
|
|||
final s = await _call('aria2.tellStatus', [
|
||||
gid,
|
||||
['status', 'totalLength', 'completedLength', 'downloadSpeed', 'followedBy',
|
||||
'bittorrent', 'files', 'errorMessage'],
|
||||
'bittorrent', 'files', 'errorMessage', 'connections'],
|
||||
]) as Map;
|
||||
// 마그넷: 메타데이터를 받은 뒤 실제 다운로드로 넘어감
|
||||
final followed = s['followedBy'] as List?;
|
||||
|
|
@ -180,6 +213,26 @@ class Aria2Backend implements DownloadBackend {
|
|||
..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
|
||||
|
|
@ -266,3 +319,23 @@ class Aria2Backend implements DownloadBackend {
|
|||
_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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class PlatformServices {
|
|||
fallbackCookieArgs: () => ytDlpCookieArgs(
|
||||
browser: internalBrowserCookies, internalCookieFile: s().internalCookieFile),
|
||||
),
|
||||
Aria2Backend(),
|
||||
Aria2Backend(stallAfter: () => Duration(seconds: s().torrentStallSeconds)),
|
||||
],
|
||||
shell: DesktopShell(),
|
||||
updater: GitHubUpdater(),
|
||||
|
|
|
|||
|
|
@ -315,7 +315,18 @@ class _DownloadRow extends StatelessWidget {
|
|||
// 136: 실패하면 진행 막대 ("준비 중…") 를 지우고 실패 · 이유 (사람 말) · [자세히] · [다시 시도]
|
||||
if (t.state != DownloadState.cancelled && t.state != DownloadState.failed) DownloadProgressBar(t: t),
|
||||
const SizedBox(height: 4),
|
||||
if (t.state == DownloadState.failed)
|
||||
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 - 이유 후보와 [다시 시도] (아무 표시 없이 "받는 중" 이던 것)
|
||||
if (t.state == DownloadState.downloading && t.extra['stalled'] == true)
|
||||
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
||||
Text(
|
||||
'${trf('트래커 · 피어에 닿지 않습니다 ({0}초째)', [((DateTime.now().millisecondsSinceEpoch - ((t.extra['stallSince'] as int?) ?? 0)) / 1000).round()])} · '
|
||||
'${trf('이유 후보: {0}', [[for (final r in (t.extra['stallReasons'] as List?) ?? const []) stallReasonText('$r')].join(' · ')])}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
if (((t.extra['stallReasons'] as List?) ?? const []).contains('dns') && d.useSystemDns != null)
|
||||
TextButton(style: _small, onPressed: () => d.useSystemDns!(), child: Text(tr('시스템 DNS 로 바꾸기'))),
|
||||
TextButton(style: _small, onPressed: () => d.retryStalled(t), child: Text(tr('다시 시도'))),
|
||||
])
|
||||
else if (t.state == DownloadState.failed)
|
||||
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
||||
Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||
if ((t.error ?? '').trim().isNotEmpty)
|
||||
|
|
@ -361,6 +372,13 @@ class _DownloadRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 멈춘 마그넷 · 토렌트의 이유 후보 글 (Aria2Backend 가 정한 이름)
|
||||
String stallReasonText(String code) => switch (code) {
|
||||
'ipv6only' => tr('인터넷 IPv4 가 없음 (IPv6 만)'),
|
||||
'dns' => tr('DNS 강제 (환경 설정 > 네트워크 · DNS)'),
|
||||
_ => tr('방화벽 · 공유기가 토렌트를 막음'),
|
||||
};
|
||||
|
||||
/// "받은 크기 / 전체 크기" (다 받았으면 전체 크기만, 모르면 빈 글)
|
||||
String downloadSizeText(DownloadTask t) {
|
||||
final got = t.receivedBytes, all = t.totalBytes;
|
||||
|
|
|
|||
|
|
@ -961,6 +961,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
title: Text(tr('완료 시 자동 동영상 추가')),
|
||||
subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
|
||||
),
|
||||
// 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 알림 (처음 60초)
|
||||
SettingTile(
|
||||
title: Text(tr('마그넷 · 토렌트가 시작되지 않으면 알리기')),
|
||||
subtitle: Text(tr('메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다')),
|
||||
trailing: DropdownButton<int>(
|
||||
value: s.torrentStallSeconds,
|
||||
items: [
|
||||
for (final v in const [30, 60, 120, 300]) DropdownMenuItem(value: v, child: Text(trf('{0}초', [v]))),
|
||||
DropdownMenuItem(value: 0, child: Text(tr('알리지 않음'))),
|
||||
],
|
||||
onChanged: (v) => c.updateSettings((x) => x.torrentStallSeconds = v!),
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('YouTube 받을 형식')),
|
||||
subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import 'dart:io';
|
||||
|
||||
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/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/aria2_backend.dart';
|
||||
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
|
||||
class _Backend implements DownloadBackend {
|
||||
@override
|
||||
DownloadKind get kind => DownloadKind.torrent;
|
||||
final started = <String>[];
|
||||
var cancelled = 0;
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
started.add(t.source);
|
||||
t.state = DownloadState.downloading;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(DownloadTask t) async {}
|
||||
@override
|
||||
Future<void> cancel(DownloadTask t) async {
|
||||
cancelled++;
|
||||
t.state = DownloadState.cancelled;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 28 확인 (10/20): 마그넷이 이 PC (IPv6 는 Tailscale fd7a:… 뿐) 에서 시작되지 않던 것 - 인터넷 IPv6 판단 · 멈춘 줄 안내
|
||||
void main() {
|
||||
InternetAddress a(String s) => InternetAddress(s);
|
||||
|
||||
test('인터넷 IPv6: 전역 2000::/3 만 - Tailscale (fd7a:115c:a1e0::) · ULA · 링크 로컬 · Teredo · 6to4 는 아님', () {
|
||||
expect(hasInternetIpv6([a('fd7a:115c:a1e0::1234')]), isFalse, reason: 'Tailscale 뿐');
|
||||
expect(hasInternetIpv6([a('fe80::1'), a('fdab::5')]), isFalse);
|
||||
expect(hasInternetIpv6([a('2001:0:4136:e378::1')]), isFalse, reason: 'Teredo');
|
||||
expect(hasInternetIpv6([a('2002:c000:204::1')]), isFalse, reason: '6to4');
|
||||
expect(hasInternetIpv6([a('fd7a:115c:a1e0::1'), a('2406:5900:1:2::10')]), isTrue);
|
||||
expect(hasInternetIpv6([a('192.168.0.5')]), isFalse);
|
||||
});
|
||||
|
||||
test('인터넷 IPv4: 루프백 · 169.254 · Tailscale 100.64/10 은 빼고 (사설 주소는 공유기 뒤라 넣음)', () {
|
||||
expect(hasInternetIpv4([a('127.0.0.1'), a('169.254.3.4'), a('100.80.195.34')]), isFalse);
|
||||
expect(hasInternetIpv4([a('192.168.88.105')]), isTrue);
|
||||
expect(hasInternetIpv4([a('100.128.0.1')]), isTrue, reason: '100.64/10 밖');
|
||||
});
|
||||
|
||||
test('설정: 시작되지 않으면 알리기 - 처음 60초, 모르는 값은 60', () {
|
||||
expect(AppSettings().torrentStallSeconds, 60);
|
||||
expect(AppSettings.fromJson({'torrentStallSeconds': 0}).torrentStallSeconds, 0);
|
||||
expect(AppSettings.fromJson({'torrentStallSeconds': 7}).torrentStallSeconds, 60);
|
||||
});
|
||||
|
||||
testWidgets('멈춘 마그넷 줄: "트래커 · 피어에 닿지 않습니다" · 이유 후보 · [다시 시도] (지금 것을 지우고 새로 넣음)', (tester) async {
|
||||
final b = _Backend();
|
||||
final s = AppSettings()..dnsMode = 'custom';
|
||||
final d = DownloadManager(backends: [b], settings: () => s);
|
||||
final t = d.add('magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0')!;
|
||||
await tester.pump();
|
||||
t.state = DownloadState.downloading;
|
||||
t.extra
|
||||
..['gid'] = 'abc'
|
||||
..['stallSince'] = DateTime.now().millisecondsSinceEpoch - 61000
|
||||
..['stalled'] = true
|
||||
..['stallReasons'] = ['ipv6only', 'firewall', 'dns'];
|
||||
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
|
||||
await tester.pump();
|
||||
expect(find.textContaining(RegExp(r'트래커 · 피어에 닿지 않습니다 \(6[1-3]초째\)')), findsOneWidget);
|
||||
expect(find.textContaining('인터넷 IPv4 가 없음 (IPv6 만) · 방화벽 · 공유기가 토렌트를 막음 · DNS 강제'), findsOneWidget);
|
||||
await tester.tap(find.text('다시 시도'));
|
||||
await tester.pump();
|
||||
expect(b.cancelled, 1);
|
||||
expect(t.extra.containsKey('gid'), isFalse, reason: '새로 추가');
|
||||
expect(t.extra.containsKey('stalled'), isFalse);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(b.started, hasLength(2), reason: '다시 시작');
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue