WIP 184-2 확장자 없는 주소의 동영상이 아닌 파일: 후처리 실패해도 받은 파일을 원래 이름으로 남기고 완료 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-12 08:15:38 +09:00
parent a4a5c4289a
commit 12db3d43b1
3 changed files with 129 additions and 0 deletions

View File

@ -1,3 +1,5 @@
import 'package:path/path.dart' as p;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
/// 다운로드 종류 /// 다운로드 종류
enum DownloadKind { enum DownloadKind {
@ -275,6 +277,33 @@ String toNetscapeCookies(Iterable<CookieRecord> cookies) {
/// yt-dlp 오류를 알기 쉬운 안내로 /// 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) { bool isCookieReadError(String err) {
final e = err.toLowerCase(); final e = err.toLowerCase();
return e.contains('cookie') && return e.contains('cookie') &&

View File

@ -244,6 +244,12 @@ class YtDlpBackend implements DownloadBackend {
return start(t, changed); return start(t, changed);
} }
} }
// 184-2: 동영상이 아닌 직접 파일 (확장자로 미리 알 수 없던 주소) 을 다 받았는데 후처리에서 실패 -
// 받은 파일을 원래 이름으로 남기고 "완료" (예전에는 다 받고도 "실패 · 받지 못했습니다" 와 "… [id].unknown_video")
if (isNonVideoPostprocessError(err) && await _keepAsPlainFile(t)) {
changed();
return;
}
t t
..state = DownloadState.failed ..state = DownloadState.failed
// 136: 원문 그대로 둔다 (화면은 friendlyDownloadError 로 사람 말 이유, [자세히] 에 원문) // 136: 원문 그대로 둔다 (화면은 friendlyDownloadError 로 사람 말 이유, [자세히] 에 원문)
@ -252,6 +258,34 @@ class YtDlpBackend implements DownloadBackend {
changed(); changed();
} }
/// 184-2: 받은 파일 (yt-dlp 가 남긴 것) 을 원래 이름으로 바꾸고 완료로. 받은 파일이 없으면 false (실패 그대로)
Future<bool> _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<void> _stop(DownloadTask t) async { Future<void> _stop(DownloadTask t) async {
final proc = _procs[t.id]; final proc = _procs[t.id];
if (proc == null) return; if (proc == null) return;

View File

@ -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);
});
}