JJ_MKVMaker/lib/app/model_backup.dart

172 lines
6.0 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'component_store.dart' show AccumulatorSink;
/// P0 (되돌리기): Android 에서 예전 버전으로 되돌리면 앱을 지워야 해서 앱 안에 받은 AI 모델이 사라진다.
/// 공용 폴더 (Download/JJ_MKVMaker/AI 모델 보관) 에 옮겨 두었다가, 다시 설치한 앱 (이 기능이 있는 판) 이 켜질 때
/// SHA-256 을 확인하며 되살리고 공용 폴더의 사본은 지운다.
///
/// [roots]: 보관할 폴더들 (이름 → 경로, 예: 'ai' → 앱 데이터/ai, 'models' → 자막 모델 폴더)
class ModelBackup {
ModelBackup(this.dir);
/// 보관 폴더 (Download/JJ_MKVMaker/AI 모델 보관)
final String dir;
/// 공용 폴더 Download/JJ_MKVMaker (Android, main 에서 정함)
static String? sharedRoot;
static ModelBackup? get shared => sharedRoot == null ? null : ModelBackup(p.join(sharedRoot!, 'AI 모델 보관'));
File get _manifest => File(p.join(dir, 'manifest.json'));
/// 되살릴 보관본이 있는지
bool get exists => _manifest.existsSync();
/// 받다 만 조각 · 임시 파일은 보관하지 않는다
static bool _skip(String path) {
final n = p.basename(path).toLowerCase();
return n.endsWith('.part') || n.endsWith('.jjpart') || n.endsWith('.tmp');
}
static Iterable<(String root, String rel, File f)> _files(Map<String, String> roots) sync* {
for (final e in roots.entries) {
final d = Directory(e.value);
if (!d.existsSync()) continue;
for (final f in d.listSync(recursive: true, followLinks: false).whereType<File>()) {
if (_skip(f.path)) continue;
yield (e.key, p.relative(f.path, from: e.value), f);
}
}
}
/// 보관할 크기 (바이트)
static int sizeOf(Map<String, String> roots) {
var n = 0;
for (final (_, _, f) in _files(roots)) {
try {
n += f.lengthSync();
} catch (_) {}
}
return n;
}
/// 파일을 복사하며 SHA-256 을 잰다
static Future<String> _copyHashed(File from, File to, void Function(int n)? onBytes) async {
await to.parent.create(recursive: true);
final out = to.openWrite();
final sink = AccumulatorSink<Digest>();
final hash = sha256.startChunkedConversion(sink);
try {
await for (final chunk in from.openRead()) {
out.add(chunk);
hash.add(chunk);
onBytes?.call(chunk.length);
}
} finally {
await out.close();
}
hash.close();
return sink.events.single.toString();
}
/// [roots] 의 파일을 보관 폴더로 복사하고 목록 (경로 · 크기 · SHA-256) 을 남긴다. 실패하면 만든 것을 지우고 다시 던진다.
Future<int> save(Map<String, String> roots, {void Function(int done, int total)? onProgress}) async {
final d = Directory(dir);
if (d.existsSync()) await d.delete(recursive: true);
final total = sizeOf(roots);
var done = 0;
final list = <Map<String, Object>>[];
try {
for (final (root, rel, f) in _files(roots).toList()) {
final sha = await _copyHashed(f, File(p.join(dir, root, rel)), (n) {
done += n;
onProgress?.call(done, total);
});
list.add({'root': root, 'rel': p.posix.joinAll(p.split(rel)), 'size': f.lengthSync(), 'sha256': sha});
}
// 목록은 마지막에 (목록이 있으면 다 복사된 것)
await _manifest.writeAsString(jsonEncode({'saved': DateTime.now().toIso8601String(), 'files': list}));
} catch (_) {
try {
await d.delete(recursive: true);
} catch (_) {}
rethrow;
}
return total;
}
/// 보관한 크기 (목록 기준)
int get savedSize {
try {
final j = jsonDecode(_manifest.readAsStringSync()) as Map;
return (j['files'] as List).fold<int>(0, (a, e) => a + ((e as Map)['size'] as num).toInt());
} catch (_) {
return 0;
}
}
/// 되살린다: 이미 같은 크기로 있으면 건너뛰고, 복사한 것은 SHA-256 을 목록과 맞춰 본다.
/// 모두 되살렸으면 보관 폴더를 지운다. 맞지 않거나 실패한 파일은 [RestoreResult.failed] (보관본은 남김).
Future<RestoreResult> restore(Map<String, String> roots, {void Function(int done, int total)? onProgress}) async {
final j = jsonDecode(await _manifest.readAsString()) as Map;
final files = [for (final e in j['files'] as List) Map<String, Object?>.from(e as Map)];
final total = files.fold<int>(0, (a, e) => a + (e['size'] as num).toInt());
var done = 0, restored = 0, skipped = 0;
final failed = <String>[];
for (final e in files) {
final base = roots['${e['root']}'];
final rel = '${e['rel']}';
final size = (e['size'] as num).toInt();
if (base == null) {
failed.add(rel);
continue;
}
final target = File(p.join(base, p.joinAll(p.posix.split(rel))));
if (target.existsSync() && target.lengthSync() == size) {
skipped++;
done += size;
onProgress?.call(done, total);
continue;
}
final tmp = File('${target.path}.jjpart');
try {
final sha = await _copyHashed(File(p.join(dir, '${e['root']}', p.joinAll(p.posix.split(rel)))), tmp, (n) {
done += n;
onProgress?.call(done, total);
});
if (sha != e['sha256']) {
await tmp.delete();
failed.add(rel);
continue;
}
if (target.existsSync()) await target.delete();
await tmp.rename(target.path);
restored++;
} catch (_) {
try {
if (tmp.existsSync()) await tmp.delete();
} catch (_) {}
failed.add(rel);
}
}
if (failed.isEmpty) {
try {
await Directory(dir).delete(recursive: true);
} catch (_) {}
}
return RestoreResult(restored, skipped, failed);
}
}
class RestoreResult {
const RestoreResult(this.restored, this.skipped, this.failed);
final int restored;
final int skipped;
final List<String> failed;
}