From 12db3d43b17c4d72d1d9fb67648fbcfdfba93dd0 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:15:38 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20184-2=20=ED=99=95=EC=9E=A5=EC=9E=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=A3=BC=EC=86=8C=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=98=81=EC=83=81=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC:=20=ED=9B=84=EC=B2=98=EB=A6=AC=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=B4=EB=8F=84=20=EB=B0=9B=EC=9D=80=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=9B=90=EB=9E=98=20=EC=9D=B4=EB=A6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/core/download_detect.dart | 29 +++++++++++ lib/platform/windows/ytdlp_backend.dart | 34 +++++++++++++ test/ytdlp_plain_file_test.dart | 66 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 test/ytdlp_plain_file_test.dart diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart index e54b471..5a97e6d 100644 --- a/lib/core/download_detect.dart +++ b/lib/core/download_detect.dart @@ -1,3 +1,5 @@ +import 'package:path/path.dart' as p; + import '../l10n/tr.dart'; /// 다운로드 종류 enum DownloadKind { @@ -275,6 +277,33 @@ String toNetscapeCookies(Iterable cookies) { /// yt-dlp 오류를 알기 쉬운 안내로 /// 쿠키를 읽지 못해 난 오류인지 (브라우저가 켜져 있음 · 암호 해독 실패 · 파일 없음 등). /// 이런 오류는 쿠키 없이 다시 받으면 되는 경우가 많다. +/// 184-2: 동영상 받기 (yt-dlp) 가 동영상이 아닌 파일을 다 받은 뒤 후처리 (FFmpeg) 에서 "동영상이 아님" 으로 실패했는지. +/// 그때는 받은 파일을 원래 이름으로 남기고 끝 (다 받은 뒤 "실패" 로 두지 않는다) +bool isNonVideoPostprocessError(String err) => + err.contains('Postprocessing') && (err.contains('Invalid data found') || err.contains('Error opening input')); + +/// 184-2: 그 파일의 원래 이름 - 주소의 물음표 뒤에 파일 이름이 있으면 그것 (download.php?file=a.zip), +/// 아니면 주소 경로의 끝 (스크립트 이름이 아니면), 그것도 아니면 받은 이름에서 " [id]" · ".unknown_video" 를 뺀 것 +String nonVideoFileName(String url, String downloaded) { + final u = Uri.tryParse(url.trim()); + bool fileLike(String s) { + final ext = p.extension(s).toLowerCase(); + return ext.length > 1 && !const ['.php', '.asp', '.aspx', '.jsp', '.cgi', '.html', '.htm', '.do', '.action'].contains(ext); + } + + if (u != null) { + for (final v in u.queryParameters.values) { + final base = p.basename(v); + if (fileLike(base)) return base; + } + final seg = u.pathSegments.where((s) => s.isNotEmpty).lastOrNull; + if (seg != null && fileLike(seg)) return seg; + } + var name = p.basename(downloaded).replaceFirst(RegExp(r'\.unknown_video$'), ''); + name = name.replaceFirst(RegExp(r' \[[^\]]*\](?=(\.[^.]*)?$)'), ''); + return name.isEmpty ? p.basename(downloaded) : name; +} + bool isCookieReadError(String err) { final e = err.toLowerCase(); return e.contains('cookie') && diff --git a/lib/platform/windows/ytdlp_backend.dart b/lib/platform/windows/ytdlp_backend.dart index c38b0c9..3c2f7ac 100644 --- a/lib/platform/windows/ytdlp_backend.dart +++ b/lib/platform/windows/ytdlp_backend.dart @@ -244,6 +244,12 @@ class YtDlpBackend implements DownloadBackend { return start(t, changed); } } + // 184-2: 동영상이 아닌 직접 파일 (확장자로 미리 알 수 없던 주소) 을 다 받았는데 후처리에서 실패 - + // 받은 파일을 원래 이름으로 남기고 "완료" (예전에는 다 받고도 "실패 · 받지 못했습니다" 와 "… [id].unknown_video") + if (isNonVideoPostprocessError(err) && await _keepAsPlainFile(t)) { + changed(); + return; + } t ..state = DownloadState.failed // 136: 원문 그대로 둔다 (화면은 friendlyDownloadError 로 사람 말 이유, [자세히] 에 원문) @@ -252,6 +258,34 @@ class YtDlpBackend implements DownloadBackend { changed(); } + /// 184-2: 받은 파일 (yt-dlp 가 남긴 것) 을 원래 이름으로 바꾸고 완료로. 받은 파일이 없으면 false (실패 그대로) + Future _keepAsPlainFile(DownloadTask t) async { + final got = [for (final f in t.files) if (File(f).existsSync()) f]; + if (got.isEmpty) return false; + final from = got.last; + var to = p.join(p.dirname(from), nonVideoFileName(t.source, from)); + if (!p.equals(to, from)) { + // 같은 이름이 있으면 덮지 않고 "이름 (2)" + final base = p.basenameWithoutExtension(to), ext = p.extension(to); + for (var i = 2; File(to).existsSync() && i < 1000; i++) { + to = p.join(p.dirname(from), '$base ($i)$ext'); + } + await File(from).rename(to); + } + t.files + ..clear() + ..add(to); + t + ..title = p.basename(to) + ..state = DownloadState.done + ..progress = 1 + ..speed = '' + ..eta = '' + ..error = null; + t.extra['plainFile'] = true; + return true; + } + Future _stop(DownloadTask t) async { final proc = _procs[t.id]; if (proc == null) return; diff --git a/test/ytdlp_plain_file_test.dart b/test/ytdlp_plain_file_test.dart new file mode 100644 index 0000000..4362452 --- /dev/null +++ b/test/ytdlp_plain_file_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:path/path.dart' as p; + +/// 184-2: 확장자가 없는 주소의 동영상이 아닌 파일 - yt-dlp 가 다 받은 뒤 후처리에서 실패해도 받은 파일을 원래 이름으로 남기고 완료 +void main() { + test('원래 이름: 물음표 뒤 파일 이름 → 경로 끝 → 받은 이름에서 [id] · .unknown_video 빼기', () { + expect(nonVideoFileName('https://a.com/download.php?id=3&file=docs/report.zip', 'x [1].unknown_video'), 'report.zip'); + expect(nonVideoFileName('https://a.com/files/setup.exe?token=abc', 'x'), 'setup.exe'); + expect(nonVideoFileName('https://a.com/get.php?id=3', r'C:\d\get [get].unknown_video'), 'get'); + expect(nonVideoFileName('https://a.com/dl/12345', r'C:\d\12345 [12345].bin'), '12345.bin'); + }); + + test('후처리 "동영상이 아님" 오류만 고른다', () { + expect(isNonVideoPostprocessError('ERROR: Postprocessing: Error opening input files: Invalid data found when processing input'), isTrue); + expect(isNonVideoPostprocessError('ERROR: [generic] Unable to download webpage: HTTP Error 404'), isFalse); + }); + + test('yt-dlp 가 받은 뒤 후처리에서 실패 → 원래 이름으로 남기고 완료, 같은 이름은 덮지 않음', () async { + final dir = Directory.systemTemp.createTempSync('jj_plain_'); + addTearDown(() => dir.deleteSync(recursive: true)); + File(p.join(dir.path, 'report.zip')).writeAsStringSync('old'); + // 가짜 yt-dlp: 파일을 만들고 "Destination" 을 알린 뒤 후처리 오류로 끝 + final script = File(p.join(dir.path, 'fake_ytdlp.py')) + ..writeAsStringSync(r''' +import os, sys +a = sys.argv[1:] +d = a[a.index('-P') + 1] +f = os.path.join(d, 'download [download].unknown_video') +open(f, 'w').write('PK') +print('[download] Destination: ' + f, flush=True) +sys.stderr.write('ERROR: Postprocessing: Error opening input files: Invalid data found when processing input\n') +sys.exit(1) +'''); + final out = Directory(p.join(dir.path, 'out'))..createSync(); + File(p.join(out.path, 'report.zip')).writeAsStringSync('old'); + final backend = YtDlpBackend(ytdlp: 'python', prefixArgs: [script.path], deno: ''); + final t = DownloadTask( + id: '1', kind: DownloadKind.video, source: 'https://a.com/download.php?file=report.zip', dir: out.path); + await backend.start(t, () {}); + expect(t.state, DownloadState.done, reason: t.error); + expect(t.files, [p.join(out.path, 'report (2).zip')]); + expect(File(t.files.single).readAsStringSync(), 'PK'); + expect(File(p.join(out.path, 'report.zip')).readAsStringSync(), 'old', reason: '있던 파일은 덮지 않음'); + expect(File(p.join(out.path, 'download [download].unknown_video')).existsSync(), isFalse); + }); + + test('받은 파일이 없으면 그대로 실패', () async { + final dir = Directory.systemTemp.createTempSync('jj_plain_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final script = File(p.join(dir.path, 'fake_ytdlp.py')) + ..writeAsStringSync(r''' +import sys +sys.stderr.write('ERROR: Postprocessing: Invalid data found when processing input\n') +sys.exit(1) +'''); + final backend = YtDlpBackend(ytdlp: 'python', prefixArgs: [script.path], deno: ''); + final t = DownloadTask(id: '1', kind: DownloadKind.video, source: 'https://a.com/x', dir: dir.path); + await backend.start(t, () {}); + expect(t.state, DownloadState.failed); + }); +}