379 lines
14 KiB
Dart
379 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../core/download_detect.dart';
|
|
import '../core/playlist.dart';
|
|
import '../services/downloader.dart';
|
|
import 'net_guard.dart';
|
|
import 'settings.dart';
|
|
import '../l10n/tr.dart';
|
|
|
|
/// 다운로드 목록 · 클립보드 감시 (플랫폼 무관)
|
|
class DownloadManager extends ChangeNotifier {
|
|
final Map<DownloadKind, DownloadBackend> backends;
|
|
final AppSettings Function() settings;
|
|
|
|
/// 클립보드 읽기 (테스트에서 바꿔 끼움)
|
|
final Future<String?> Function() readClipboard;
|
|
|
|
DownloadManager({
|
|
required List<DownloadBackend> backends,
|
|
required this.settings,
|
|
Future<String?> Function()? readClipboard,
|
|
}) : backends = {for (final b in backends) b.kind: b},
|
|
readClipboard = readClipboard ?? _systemClipboard;
|
|
|
|
static Future<String?> _systemClipboard() async =>
|
|
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
|
|
|
|
final List<DownloadTask> tasks = [];
|
|
final Set<String> selected = {};
|
|
int _nextId = 0;
|
|
Timer? _clipTimer;
|
|
String? _lastClip;
|
|
|
|
/// 받은 동영상을 편집 목록(MKV 만들기)에 넣는 함수 (새로 넣은 개수를 돌려줌). 앱이 연결해 준다.
|
|
Future<int> Function(List<String> files)? addToEditList;
|
|
|
|
/// "완료 시 자동 동영상 추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다.
|
|
Future<void> Function(bool on)? setAutoAdd;
|
|
|
|
/// 263: DNS 를 시스템 DNS 로 바꾸는 함수 (설정에 저장). 앱이 연결해 준다 - 실패한 줄의 [시스템 DNS 로 바꾸기]
|
|
Future<void> Function()? useSystemDns;
|
|
|
|
/// 263: 이 실패가 정한 DNS 서버에 닿지 않아서인지 - 그 서버 (아니면 null).
|
|
/// DNS 강제가 켜져 있고 "닿지 않음" 상태이며, 오류가 연결 · 프록시 실패일 때만 (404 · 로그인 같은 다른 실패는 DNS 탓으로 보지 않는다)
|
|
static String? dnsCause(DownloadTask t, {NetGuard? guard}) {
|
|
final g = guard ?? NetGuard.instance;
|
|
final f = g.failure.value;
|
|
if (f == null || !g.config().forced) return null;
|
|
final err = t.error ?? '';
|
|
final e = err.toLowerCase();
|
|
if (!e.contains('proxy') && !e.contains('tunnel') && friendlyDownloadError(err) != tr('연결할 수 없습니다')) return null;
|
|
return f.server;
|
|
}
|
|
|
|
/// 설정이 바뀌었을 때 화면 갱신
|
|
void refresh() => notifyListeners();
|
|
|
|
/// 목록에서만 뺀다 (받은 파일은 그대로)
|
|
void dropFromList(DownloadTask t) {
|
|
tasks.remove(t);
|
|
selected.remove(t.id);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 고른 다운로드 중 다 받은 것의 동영상 파일
|
|
List<String> get selectedVideoFiles => [
|
|
for (final t in _sel)
|
|
if (t.state == DownloadState.done) ...videoFilesOf(t),
|
|
];
|
|
|
|
/// 새로 추가된 다운로드 알림 (화면에 안내 표시용)
|
|
final _added = StreamController<DownloadTask>.broadcast();
|
|
Stream<DownloadTask> get onAdded => _added.stream;
|
|
|
|
/// 다 받은 다운로드 알림 (편집 목록 자동 추가용). 한 건에 한 번만.
|
|
final _finished = StreamController<DownloadTask>.broadcast();
|
|
Stream<DownloadTask> get onFinished => _finished.stream;
|
|
final Set<String> _reported = {};
|
|
|
|
/// 받은 파일 중 동영상 (임시 · 조각 파일 제외, 실제로 있는 것만)
|
|
static List<String> videoFilesOf(DownloadTask t, {bool Function(String)? exists}) {
|
|
final ok = exists ?? (String f) => File(f).existsSync();
|
|
final frag = RegExp(r'\.f\d+\.[^.]+$|\.part$|\.temp\.|\.ytdl$', caseSensitive: false);
|
|
return [
|
|
for (final f in t.files)
|
|
if (isVideoFile(f) && !frag.hasMatch(f) && ok(f)) f,
|
|
];
|
|
}
|
|
|
|
int get activeCount => tasks.where((t) => t.unfinished).length;
|
|
int get downloadingCount => tasks.where((t) => t.state == DownloadState.downloading).length;
|
|
|
|
/// 진행 중 작업의 평균 진행률
|
|
double? get overallProgress {
|
|
final act = tasks.where((t) => t.state == DownloadState.downloading && t.progress != null);
|
|
if (act.isEmpty) return null;
|
|
return act.map((t) => t.progress!).reduce((a, b) => a + b) / act.length;
|
|
}
|
|
|
|
// ───────── 클립보드 감시 ─────────
|
|
|
|
/// 1초마다 클립보드를 확인. 처음 시작할 때 이미 있던 내용은 무시한다.
|
|
Future<void> startClipboardWatch() async {
|
|
_clipTimer?.cancel();
|
|
_lastClip = await _safeRead();
|
|
_clipTimer = Timer.periodic(const Duration(seconds: 1), (_) => checkClipboard());
|
|
}
|
|
|
|
void stopClipboardWatch() {
|
|
_clipTimer?.cancel();
|
|
_clipTimer = null;
|
|
}
|
|
|
|
Future<String?> _safeRead() async {
|
|
try {
|
|
return await readClipboard();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 클립보드가 바뀌었으면 주소를 찾아 다운로드 추가
|
|
Future<List<DownloadTask>> checkClipboard() async {
|
|
if (!settings().clipboardWatch) return const [];
|
|
final text = await _safeRead();
|
|
if (text == null || text == _lastClip) return const [];
|
|
_lastClip = text;
|
|
final added = <DownloadTask>[];
|
|
for (final link in detectLinks(text)) {
|
|
final t = add(link.url);
|
|
if (t != null) added.add(t);
|
|
}
|
|
return added;
|
|
}
|
|
|
|
// ───────── 추가 · 제어 ─────────
|
|
|
|
/// 주소 추가 후 바로 시작. 이미 받는 중인 주소거나 알 수 없는 주소면 null.
|
|
DownloadTask? add(String text) {
|
|
final links = detectLinks(text.trim());
|
|
if (links.isEmpty) return null;
|
|
final link = links.first;
|
|
if (tasks.any((t) => t.source == link.url && t.state != DownloadState.cancelled)) return null;
|
|
final backend = backends[link.kind];
|
|
if (backend == null) return null;
|
|
final s = settings();
|
|
final t = DownloadTask(
|
|
id: '${_nextId++}',
|
|
kind: link.kind,
|
|
source: link.url,
|
|
dir: link.kind == DownloadKind.video ? s.ytDlpDir : s.aria2Dir,
|
|
);
|
|
tasks.insert(0, t);
|
|
_added.add(t);
|
|
// YouTube 재생목록: 목록을 읽어 영상마다 한 줄씩 바꿔 넣는다
|
|
if (link.kind == DownloadKind.video && s.ytExpandPlaylists && youtubePlaylistId(link.url) != null) {
|
|
t
|
|
..expanding = true
|
|
..title = trf('재생목록 불러오는 중… {0}', [link.url]);
|
|
notifyListeners();
|
|
unawaited(_expand(t, backend));
|
|
return t;
|
|
}
|
|
notifyListeners();
|
|
_pump();
|
|
return t;
|
|
}
|
|
|
|
/// 브라우저에서 보고 있는 페이지 받기 (상단 [다운로드] 버튼).
|
|
/// YouTube · 마그넷 · torrent 는 [add] 와 같고, 그 밖의 http 주소는 yt-dlp 로 받는다.
|
|
DownloadTask? addPage(String url) {
|
|
if (detectLinks(url).isNotEmpty) return add(url);
|
|
if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null;
|
|
if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null;
|
|
// 184: 동영상이 아닌 직접 파일 주소 (zip 등) 는 일반 파일 받기 (aria2) 로 - 원래 이름 그대로
|
|
if (isDirectFileUrl(url) && backends[DownloadKind.torrent] != null) {
|
|
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.torrent, source: url.trim(), dir: settings().aria2Dir)
|
|
..title = Uri.tryParse(url.trim())?.pathSegments.lastOrNull ?? url;
|
|
tasks.insert(0, t);
|
|
_added.add(t);
|
|
_changed();
|
|
return t;
|
|
}
|
|
if (backends[DownloadKind.video] == null) return null;
|
|
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir);
|
|
tasks.insert(0, t);
|
|
_added.add(t);
|
|
_changed();
|
|
return t;
|
|
}
|
|
|
|
Future<void> _expand(DownloadTask holder, DownloadBackend backend) async {
|
|
try {
|
|
final r = await backend.expandPlaylist(holder.source);
|
|
final i = tasks.indexOf(holder);
|
|
if (i < 0) return; // 불러오는 동안 삭제됨
|
|
if (r == null || r.$2.isEmpty) {
|
|
// 재생목록이 아니면 영상 하나로 받음
|
|
holder
|
|
..expanding = false
|
|
..title = holder.source;
|
|
_changed();
|
|
return;
|
|
}
|
|
final (name, entries) = r;
|
|
final folder = safeFolderName(name);
|
|
final dir = p.join(settings().ytDlpDir, folder.isEmpty ? 'playlist' : folder);
|
|
final have = tasks.where((x) => x.state != DownloadState.cancelled).map((x) => x.source).toSet();
|
|
final items = <DownloadTask>[];
|
|
for (var k = 0; k < entries.length; k++) {
|
|
final e = entries[k];
|
|
if (have.contains(e.url)) continue;
|
|
items.add(DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: e.url, dir: dir)
|
|
..title = '[${k + 1}/${entries.length}] ${e.title}'
|
|
..extra['playlist'] = name);
|
|
}
|
|
tasks
|
|
..removeAt(i)
|
|
..insertAll(i, items);
|
|
selected.remove(holder.id);
|
|
_changed();
|
|
} catch (e) {
|
|
holder
|
|
..expanding = false
|
|
..state = DownloadState.failed
|
|
..error = e is ProcessException ? e.message : '$e';
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작
|
|
/// 작업 기록에 남기기 (앱이 연결해 준다): 다운로드 시작 · 완료 · 실패
|
|
void Function(String msg)? log;
|
|
final Map<String, DownloadState> _lastState = {};
|
|
|
|
void _changed() {
|
|
for (final t in tasks) {
|
|
if (t.state == DownloadState.done && _reported.add(t.id)) _finished.add(t);
|
|
final before = _lastState[t.id];
|
|
if (before != t.state) {
|
|
_lastState[t.id] = t.state;
|
|
final name = t.title == t.source ? t.source : '${t.title} (${t.source})';
|
|
switch (t.state) {
|
|
case DownloadState.downloading when before != DownloadState.downloading:
|
|
log?.call(trf('다운로드 시작: {0}', [name]));
|
|
case DownloadState.done:
|
|
log?.call(trf('다운로드 완료: {0}{1}', [name, t.totalBytes == null ? '' : ' (${formatBytes(t.totalBytes!)})']));
|
|
case DownloadState.failed:
|
|
// 263: 실패한 그때 DNS 서버에 닿지 않았으면 줄에 이유와 [시스템 DNS 로 바꾸기] (나중에 서버가 다시 답해도 이 줄의 이유는 남는다)
|
|
final dns = dnsCause(t);
|
|
if (dns == null) {
|
|
t.extra.remove('dnsServer');
|
|
} else {
|
|
t.extra['dnsServer'] = dns;
|
|
}
|
|
log?.call(trf('다운로드 실패: {0} - {1}', [name, t.error ?? '']));
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
notifyListeners();
|
|
_pump();
|
|
}
|
|
|
|
/// 동시 다운로드 수 안에서 대기 중인 작업을 들어온 순서대로 시작
|
|
void _pump() {
|
|
final limit = settings().maxParallelDownloads;
|
|
var running = tasks.where((t) => t.state == DownloadState.downloading).length;
|
|
final waiting = tasks.where((t) => t.state == DownloadState.queued && !t.expanding).toList()
|
|
..sort((a, b) => int.parse(a.id).compareTo(int.parse(b.id)));
|
|
for (final t in waiting) {
|
|
if (limit > 0 && running >= limit) break;
|
|
final backend = backends[t.kind];
|
|
if (backend == null) continue;
|
|
t.state = DownloadState.downloading;
|
|
running++;
|
|
unawaited(backend.start(t, _changed));
|
|
}
|
|
}
|
|
|
|
Iterable<DownloadTask> get _sel => tasks.where((t) => selected.contains(t.id));
|
|
|
|
void toggle(DownloadTask t) {
|
|
if (!selected.remove(t.id)) selected.add(t.id);
|
|
notifyListeners();
|
|
}
|
|
|
|
void selectAll() {
|
|
selected
|
|
..clear()
|
|
..addAll(tasks.map((t) => t.id));
|
|
notifyListeners();
|
|
}
|
|
|
|
void selectNone() {
|
|
selected.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> pause(Iterable<DownloadTask> list) async {
|
|
for (final t in list.toList()) {
|
|
if (t.state == DownloadState.downloading || t.state == DownloadState.queued) {
|
|
await backends[t.kind]!.pause(t);
|
|
}
|
|
}
|
|
_changed();
|
|
}
|
|
|
|
/// 다시 받기: 대기열에 넣고 차례가 되면 시작
|
|
Future<void> resume(Iterable<DownloadTask> list) async {
|
|
for (final t in list.toList()) {
|
|
if (t.state == DownloadState.paused || t.state == DownloadState.failed) {
|
|
if (t.state == DownloadState.failed) t.extra.remove('gid'); // 실패한 aria2 작업은 새로 추가
|
|
t.extra.remove('dnsServer');
|
|
t
|
|
..state = DownloadState.queued
|
|
..error = null;
|
|
}
|
|
}
|
|
_changed();
|
|
}
|
|
|
|
/// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김)
|
|
Future<void> cancel(Iterable<DownloadTask> list) async {
|
|
for (final t in list.toList()) {
|
|
if (t.unfinished) await backends[t.kind]!.cancel(t);
|
|
}
|
|
_changed();
|
|
}
|
|
|
|
/// 목록에서 삭제 (진행 중이면 취소 후 삭제)
|
|
Future<void> remove(Iterable<DownloadTask> list) async {
|
|
final items = list.toList();
|
|
await cancel(items);
|
|
for (final t in items) {
|
|
tasks.remove(t);
|
|
selected.remove(t.id);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> pauseSelected() => pause(_sel);
|
|
Future<void> resumeSelected() => resume(_sel);
|
|
Future<void> cancelSelected() => cancel(_sel);
|
|
Future<void> removeSelected() => remove(_sel);
|
|
|
|
/// 완료·취소·실패 항목 정리 (받은 파일은 그대로 둠)
|
|
void cleanupFinished() {
|
|
tasks.removeWhere((t) {
|
|
final gone = !t.unfinished;
|
|
if (gone) selected.remove(t.id);
|
|
return gone;
|
|
});
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 앱 종료: 모든 다운로드 중지
|
|
Future<void> shutdown() async {
|
|
stopClipboardWatch();
|
|
for (final b in backends.values) {
|
|
await b.shutdown();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
stopClipboardWatch();
|
|
_added.close();
|
|
_finished.close();
|
|
super.dispose();
|
|
}
|
|
}
|