import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 의 그림 · 동영상 썸네일. /// - 받는 때: 설정 'wifi' (처음 값) 면 데이터 요금제가 아닌 망에서만, 'always' 면 늘, 'off' 면 받지 않음 /// - 서버에 부담이 가지 않게 동시에 [parallel] 개까지 (처음 2). 줄 세운 것 중 화면에서 사라진 줄은 시작하지 않는다 /// - 만든 썸네일은 [dir] 에 두고, 모두 [maxBytes] 를 넘으면 오래 쓰지 않은 것부터 지운다. [clear] 로 모두 지움 /// - 만들기 ([make]) 는 부르는 쪽이 준다 (앱: FFmpeg 가 이 기기 안 스트리밍 다리에서 앞 · 뒤 일부만 읽음) class RemoteThumbs { final Directory dir; /// 캐시 상한 (바이트) int Function() maxBytes; /// 동시에 받는 수 final int parallel; /// 지금 받아도 되는지 (설정 · 망) final Future Function() allowed; /// [path] 의 썸네일을 [out] (jpg) 에 만든다 - 만들었으면 true final Future Function(String path, String out) make; RemoteThumbs({required this.dir, required this.maxBytes, required this.allowed, required this.make, this.parallel = 2}); final _done = {}; final _waiting = Queue<_Job>(); final _jobs = {}; int _running = 0; /// 시험: 지금까지 [make] 를 부른 경로 · 동시에 돈 가장 큰 수 final made = []; int peak = 0; static String keyOf(String path, int size, DateTime modified) => '$path|$size|${modified.millisecondsSinceEpoch}'; /// 이미 만든 것 (없으면 null - 만들기는 [request]) File? cached(String key) => _done[key]; /// [path] 의 기억을 잊는다 (보관함에 넣은 파일 - 관리자 지시 10/21. 파일은 removeThumbCaches 가 지움) void forget(String path) => _done.removeWhere((k, _) => k.startsWith('$path|')); /// 썸네일을 달라고 줄 세운다. [wanted] 가 시작할 때 false 면 (화면에서 사라진 줄) 건너뛴다. /// 끝나면 [onDone] (못 만들었으면 null). 받지 않는 망이면 줄 세우지 않고 null Future request(String path, String key, {required bool Function() wanted}) async { if (_done.containsKey(key)) return _done[key]; final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); if (await out.exists() && await out.length() > 0) { unawaited(out.setLastModified(DateTime.now()).catchError((_) {})); return _done[key] = out; } final old = _jobs[key]; if (old != null) { old.wanted.add(wanted); return old.result.future; } if (!await allowed()) return null; final job = _Job(path, key, out)..wanted.add(wanted); _jobs[key] = job; _waiting.add(job); _pump(); return job.result.future; } void _pump() { while (_running < parallel && _waiting.isNotEmpty) { final job = _waiting.removeFirst(); // 화면에서 사라진 줄이면 시작하지 않는다 (다시 보이면 다시 달라고 한다) if (!job.wanted.any((w) => w())) { _jobs.remove(job.key); job.result.complete(null); continue; } _running++; if (_running > peak) peak = _running; unawaited(_run(job)); } } Future _run(_Job job) async { File? got; try { if (await allowed()) { await dir.create(recursive: true); made.add(job.path); if (await make(job.path, job.out.path) && await job.out.exists() && await job.out.length() > 0) { got = job.out; _done[job.key] = got; await _trim(); } } } catch (_) { got = null; } finally { _running--; _jobs.remove(job.key); job.result.complete(got); _pump(); } } /// 캐시의 크기 (바이트) Future size() async { var n = 0; if (!await dir.exists()) return 0; await for (final f in dir.list()) { if (f is File) n += await f.length().catchError((_) => 0); } return n; } /// 상한을 넘으면 오래 쓰지 않은 것 (바뀐 때가 옛것) 부터 지워 상한의 90% 아래로 Future _trim() async { final files = <(File, int, DateTime)>[]; var total = 0; await for (final f in dir.list()) { if (f is! File) continue; final st = await f.stat(); files.add((f, st.size, st.modified)); total += st.size; } final cap = maxBytes(); if (total <= cap) return; files.sort((a, b) => a.$3.compareTo(b.$3)); for (final (f, size, _) in files) { if (total <= cap * 0.9) break; try { await f.delete(); total -= size; _done.removeWhere((_, v) => v?.path == f.path); } catch (_) {} } } /// 모두 지운다 (환경 설정 [캐시 지우기]) Future clear() async { _done.clear(); if (await dir.exists()) { await for (final f in dir.list()) { try { await f.delete(recursive: true); } catch (_) {} } } } } class _Job { final String path, key; final File out; final wanted = []; final result = Completer(); _Job(this.path, this.key, this.out); }