JJ_MKVMaker/lib/app/transfer_job.dart

470 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../core/file_ops.dart';
import '../core/sync_preview.dart' show isRsyncDeleteOption;
import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
/// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지
/// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라).
/// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다
String rsyncFailureText(int code, List<String> errLines, List<String> allLines) {
final all = [...errLines, ...allLines];
final removeFailed = all.any((l) {
final x = l.toLowerCase();
return x.contains('failed to remove') || x.contains('cannot delete') || x.contains('unable to remove');
});
bool important(String l) {
final x = l.toLowerCase();
return x.contains('error') || x.contains('fail') || x.contains('denied') || x.contains('cannot') || x.contains('permission');
}
final picked = errLines.where(important).toList();
final lines = picked.isNotEmpty ? picked : (errLines.isNotEmpty ? errLines : allLines);
final tail = lines.length > 6 ? lines.sublist(lines.length - 6) : lines;
return [
if (removeFailed) tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.'),
trf('rsync 종료 코드 {0}', [code]),
...tail,
].join('\n');
}
class TransferJob extends ChangeNotifier {
final List<String> sources;
final String dest;
final bool move;
final CopyMethod method;
/// rsync · robocopy 옵션 글자
final String options;
/// 여러 항목을 rsync 한 번으로 (false 면 항목마다 따로)
final bool once;
final int bandwidthKBps;
/// rsync 실행 파일 (rsync 방법일 때)
final String? rsyncExe;
/// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사.
final bool contents;
/// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지
final String prune;
/// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙)
final NameConflict conflict;
/// 229: 앱이 직접 맞출 때 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). 건너뛴 원본은 이동이라도 남긴다
final bool skipNewer;
/// 같은 이름이라 건너뛴 항목 (원본 경로)
final skipped = <String>[];
TransferJob({
required this.sources,
required this.dest,
required this.move,
this.method = CopyMethod.builtin,
this.options = '',
this.once = false,
this.bandwidthKBps = 0,
this.rsyncExe,
this.contents = false,
this.prune = '',
this.conflict = NameConflict.rename,
this.skipNewer = false,
});
/// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다)
String currentFile = '';
/// 지금 전송 속도 (바이트/초, 모르면 null): rsync 는 -P 가 알려 주는 값, 현재 방식은 최근 3초 동안 복사한 양
double? speed;
int _bytes = 0;
final _samples = <(DateTime, int)>[];
void _addBytes(int n) {
final now = DateTime.now();
_bytes += n;
_samples
..add((now, _bytes))
..removeWhere((x) => now.difference(x.$1).inMilliseconds > 3000);
if (_samples.length >= 2) {
final ms = now.difference(_samples.first.$1).inMilliseconds;
if (ms > 0) speed = (_bytes - _samples.first.$2) * 1000 / ms;
}
// 큰 파일 하나를 보내는 동안에도 진행 막대가 오르게 (파일이 끝날 때만 알리면 0% 에 머문다) - 0.2초마다
if (now.difference(_lastTick).inMilliseconds >= 200) {
_lastTick = now;
_tick();
}
}
DateTime _lastTick = DateTime.fromMillisecondsSinceEpoch(0);
/// 원본 정리 중 · 지운 빈 폴더 수
bool pruning = false;
int pruned = 0;
// ── 진행 상태 ──
int index = 0; // 지금 항목
int get total => sources.length;
// 171: 끝난 뒤에도 마지막 항목 이름 (빈 이름으로 " 안의 파일" 이 되던 것)
String get currentName => sources.isEmpty ? '' : p.basename(sources[index < sources.length ? index : sources.length - 1]);
late final List<int> filesTotal = List.filled(sources.length, 0);
late final List<int> filesDone = List.filled(sources.length, 0);
/// 진행 표시 (한 파일 0% 에 머물던 것): 앱이 직접 복사할 때 항목마다 바이트 크기와 보낸 바이트로 진행률을 센다.
/// 같은 드라이브에서 이름 바꾸기로 옮기면 보낸 바이트가 없으므로 파일 수로
late final List<int> itemBytes = List.filled(sources.length, 0);
int _itemStart = 0;
int get _bytesTotal => itemBytes.fold(0, (a, b) => a + b);
bool get _byBytes => _bytesTotal > 0 && _bytes > 0;
/// 지금 파일의 진행률 (rsync -P · robocopy 가 알려 줄 때)
double? filePercent;
bool counting = true;
bool finished = false;
Object? error;
bool cancelled = false;
final made = <String>[];
/// 마지막 출력 몇 줄 (실패했을 때 보여 줌)
final log = <String>[];
/// rsync 가 알려 주는 폴더 전체 확인 (to-chk) - 바뀐 파일만 이름이 나오므로 파일 수보다 정확하다. 모르면 0.
int checked = 0, checkTotal = 0;
/// 폴더 전체 진행률 (rsync 는 to-chk, 그 밖은 [overall])
double get folderProgress {
if (finished) return 1;
if (checkTotal > 0) return (checked / checkTotal).clamp(0.0, 1.0);
return overall;
}
int get allFiles => filesTotal.fold(0, (a, b) => a + b);
int get allDone => filesDone.fold(0, (a, b) => a + b);
/// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것)
int leftInSource = 0;
/// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다)
int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone;
/// 212 · 216: 끝 알림 한 줄 - "이동 끝: n개 · 원본 → 대상 · m개는 원본에 남음 (건너뜀)" / "복사 끝: n개 · 원본 → 대상"
String doneSummary() {
final from = sources.length == 1 ? vBasename(sources.first) : trf('{0}개 항목', [sources.length]);
final total = filesTotal.fold(0, (a, b) => a + b);
final n = move ? (total - leftInSource).clamp(0, total) : allDone;
final head = move ? trf('이동 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]) : trf('복사 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]);
return leftInSource > 0 ? '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}' : head;
}
/// 위쪽 진행률: 끝낸 항목 + 지금 항목의 파일 진행
double get overall {
if (finished) return 1;
if (total == 0) return 0;
if (_byBytes) return (_bytes / _bytesTotal).clamp(0.0, 1.0);
final cur = index < total && filesTotal[index] > 0 ? (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0) : 0.0;
return ((index + cur) / total).clamp(0.0, 1.0);
}
/// 아래쪽 진행률: 지금 항목의 파일
double get current {
if (finished) return 1;
if (index >= total) return 0;
if (_byBytes && itemBytes[index] > 0) return ((_bytes - _itemStart) / itemBytes[index]).clamp(0.0, 1.0);
if (filesTotal[index] == 0) return 0;
return (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0);
}
Process? _proc;
FileOps? _ops;
final _done = Completer<void>();
/// 끝날 때 (성공 · 실패 · 취소)
Future<void> get done => _done.future;
void cancel() {
cancelled = true;
_ops?.cancel();
_proc?.kill();
notifyListeners();
}
void _tick() => notifyListeners();
/// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 직접 (크기 · 시각 비교) 맞춘다
bool get viaWebDav => isDav(dest) || sources.any(isDav);
/// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀)
bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
/// 100: WebDAV 를 앱이 맞출 때도 rsync 의 지우기 (--delete 계열) 를 따른다 (확인 창에서 지울 목록을 본 뒤에만 실행됨)
bool get _delete => method == CopyMethod.rsync && splitOptions(options).any(isRsyncDeleteOption);
static Future<int> countFiles(String path) async {
if (isDav(path)) return vCountFiles(path);
if (!FileSystemEntity.isDirectorySync(path)) return 1;
var n = 0;
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
if (e is File) n++;
}
return n;
}
Future<void> run() async {
try {
for (var i = 0; i < sources.length; i++) {
final s = sources[i];
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
throw FileSystemException(tr('폴더를 자기 안으로 복사 · 이동할 수 없습니다'), s);
}
filesTotal[i] = await countFiles(s);
// 앱이 직접 복사할 때만 (robocopy · rsync 는 그 프로그램이 진행을 알려 줌)
if (viaWebDav || method == CopyMethod.builtin) {
try {
itemBytes[i] = await FileOps.totalSize([s]);
} catch (_) {}
}
}
counting = false;
_tick();
switch (viaWebDav ? CopyMethod.builtin : method) {
case CopyMethod.builtin:
await _runBuiltin();
case CopyMethod.rsync:
await _runRsync();
case CopyMethod.robocopy:
await _runRobocopy();
}
if (cancelled) throw const FileOpCancelled();
// 옮긴 뒤 원본에 남은 빈 폴더 정리 (find 원본/ -type d -empty -delete)
if (move && prune.isNotEmpty) {
pruning = true;
_tick();
for (final s in sources) {
if (isDav(s) || FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep');
}
}
// 216: 옮긴 뒤 원본에 남은 파일을 다시 센다 (옵션 -u · --exclude · 대상이 더 새것 등으로 건너뜀, 맞춘 뒤 바뀜) -
// 예전에는 남은 것이 있어도 "끝" 으로만 보였다
if (move) {
for (final s in sources) {
try {
if (await vExists(s)) leftInSource += await countFiles(s);
} catch (_) {}
}
}
} catch (e) {
error = cancelled ? const FileOpCancelled() : e;
} finally {
finished = true;
speed = null;
_tick();
if (!_done.isCompleted) _done.complete();
}
}
Future<void> _runBuiltin() async {
for (index = 0; index < sources.length; index++) {
if (cancelled) return;
final i = index;
_itemStart = _bytes;
_ops = FileOps(
bandwidthKBps: bandwidthKBps,
onFileDone: (src) {
filesDone[i]++;
currentFile = vBasename(src);
_tick();
},
// 보낸 양으로 전송 속도 (WebDAV 포함)
onBytes: _addBytes,
conflict: conflict,
// 214: 이동은 맞춘 원본 파일만 지운다
onSettled: move ? (s, size, modified) => settled[s] = (size, modified) : null,
);
_tick();
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
settled.clear();
await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move);
if (move && !cancelled) await _deleteSettled();
made.add(dest);
} else {
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
made.addAll(r);
skipped.addAll(_ops!.skipped);
}
filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우
}
index = sources.length;
}
/// 214: 이번 맞추기에서 대상에 맞춘 원본 파일 (경로 → 그때의 크기 · 바뀐 때)
final settled = <String, (int, DateTime)>{};
/// 맞춘 뒤 원본의 파일을 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리).
/// 214: 맞춘 파일만 - 건너뛴 것 (-u 로 대상이 더 새것) · 맞추는 동안 새로 생긴 것은 남긴다.
/// 맞춘 뒤 바뀐 것 (크기 · 바뀐 때가 다름) 도 남긴다. 예전에는 원본 폴더를 다시 읽어 모든 파일을 지웠다
Future<void> _deleteSettled() async {
for (final MapEntry(key: path, value: (size, modified)) in settled.entries) {
if (cancelled) return;
final now = await vStat(path);
if (now == null || now.isDir) continue;
if (now.size != size || now.modified.difference(modified).inSeconds.abs() > 2) {
skippedSources.add(path);
continue;
}
await vDelete(path);
}
}
/// 214: 이동인데 원본에 남긴 파일 (맞춘 뒤 바뀜)
final skippedSources = <String>[];
/// 215: 프로그램이 오류 출력 (stderr) 으로 낸 줄 (끝의 20줄)
final errLog = <String>[];
void _addLog(String line) {
if (line.trim().isEmpty) return;
log.add(line.trim());
if (log.length > 20) log.removeAt(0);
}
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
_proc = await Process.start(exe, args, runInShell: false);
final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
final b = _proc!.stderr.transform(enc.decoder).listen((t) {
for (final l in t.split(RegExp(r'[\r\n]'))) {
_addLog(l);
// 215: 오류 줄은 따로 (전송한 파일 이름에 밀려 실패 이유가 빠지지 않게)
if (l.trim().isNotEmpty) {
errLog.add(l.trim());
if (errLog.length > 20) errLog.removeAt(0);
}
}
});
final code = await _proc!.exitCode;
await a.cancel();
await b.cancel();
_proc = null;
return code;
}
Future<void> _runRsync() async {
final exe = rsyncExe;
if (exe == null || exe.isEmpty) throw StateError(tr('rsync 실행 파일이 없습니다'));
final windows = Platform.isWindows;
Future<void> one(List<int> idx) async {
final out = RsyncOutput();
final names = [for (final i in idx) p.basename(sources[i])];
final code = await _start(
exe,
rsyncArgs(
options: options,
sources: [for (final i in idx) sources[i]],
dest: dest,
bandwidthKBps: bandwidthKBps,
move: move,
windows: windows,
contents: contents),
utf8,
(chunk) {
for (final f in out.feed(chunk)) {
_addLog(f);
currentFile = f;
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 (안의 것 모드는 이름이 없어 지금 항목)
final first = f.split('/').first;
var k = contents ? -1 : idx.indexWhere((i) => p.basename(sources[i]) == first);
if (k < 0) k = names.indexOf(f);
final at = k < 0 ? index : idx[k];
if (at > index) index = at;
filesDone[at]++;
}
filePercent = out.currentPercent;
speed = out.currentSpeed ?? speed;
if (out.checkTotal != null) {
checkTotal = out.checkTotal!;
checked = out.checked!;
}
_tick();
},
);
if (cancelled) return;
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);
for (final i in idx) {
filesDone[i] = filesTotal[i];
made.add(p.join(dest, p.basename(sources[i])));
// 폴더째 이동 (안의 것 [contents] 이 아니고 원본 정리 [prune] 를 고르지 않음): rsync 는 파일만 지우므로
// 비어 남은 원본 폴더를 지운다. 원본 정리를 고른 이동은 끝에서 그 고른 대로 ([prune]).
if (move && !contents && prune.isEmpty && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) {
await Directory(sources[i]).delete(recursive: true);
}
}
}
if (once) {
index = 0;
await one([for (var i = 0; i < sources.length; i++) i]);
} else {
for (index = 0; index < sources.length; index++) {
if (cancelled) return;
_tick();
await one([index]);
}
}
index = sources.length;
}
Future<void> _runRobocopy() async {
// robocopy 는 폴더마다 (파일은 같은 폴더끼리) 한 번씩
for (index = 0; index < sources.length; index++) {
if (cancelled) return;
final s = sources[index];
final dir = FileSystemEntity.isDirectorySync(s);
final runs = robocopyRuns(
options: options,
folders: dir ? [s] : const [],
files: dir ? const [] : [s],
dest: dest,
bandwidthKBps: bandwidthKBps,
move: move,
contents: contents,
);
_tick();
for (final args in runs) {
final out = RobocopyOutput();
final i = index;
final code = await _start(
'robocopy',
[...args, '/BYTES', '/NJH', '/NJS', '/NDL', '/FP'],
systemEncoding,
(chunk) {
for (final f in out.feed(chunk)) {
_addLog(f);
filesDone[i]++;
}
filePercent = out.currentPercent;
_tick();
},
);
if (cancelled) return;
if (!robocopyOk(code)) throw ProcessException('robocopy', const [], trf('robocopy 종료 코드 {0}', [code]), code);
}
filesDone[index] = filesTotal[index];
made.add(p.join(dest, p.basename(s)));
}
index = sources.length;
}
}