JJ_MKVMaker/lib/platform/windows/ytdlp_backend.dart

250 lines
7.9 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../../core/download_detect.dart';
import '../../services/downloader.dart';
/// 실행 파일 찾기: 앱 옆 tools\ → 앱 폴더 → PATH
String locateTool(String name) {
final exe = Platform.isWindows ? '$name.exe' : name;
final appDir = p.dirname(Platform.resolvedExecutable);
for (final dir in [p.join(appDir, 'tools'), appDir]) {
final f = p.join(dir, exe);
if (File(f).existsSync()) return f;
}
return name;
}
/// yt-dlp 로 동영상 다운로드 (작업마다 프로세스 하나)
///
/// 일시정지 = 프로세스 중지, 다시 시작하면 yt-dlp 가 .part 파일에서 이어 받는다.
class YtDlpBackend implements DownloadBackend {
final String ytdlp;
/// yt-dlp 가 영상·음성을 합칠 때 쓰는 ffmpeg 폴더
final String? ffmpegDir;
/// YouTube 추출용 JavaScript 실행기 (없으면 일부 화질이 빠질 수 있음)
final String? deno;
final _procs = <String, Process>{};
final _stopping = <String>{};
/// 형식 · 화질 인수 (환경 설정에서 읽음). 없으면 MP4 최고 화질.
final List<String> Function() formatArgs;
/// 쿠키 인수 (YouTube 로봇 확인 대응, 환경 설정에서 읽음)
final List<String> Function() cookieArgs;
YtDlpBackend({
String? ytdlp,
this.ffmpegDir,
String? deno,
List<String> Function()? formatArgs,
List<String> Function()? cookieArgs,
}) : ytdlp = ytdlp ?? locateTool('yt-dlp'),
deno = deno ?? _existing(locateTool('deno')),
formatArgs = formatArgs ?? (() => ytDlpFormatArgs(YtContainer.mp4, YtQuality.best)),
cookieArgs = cookieArgs ?? (() => const []);
static const _env = {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'};
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async {
final listId = youtubePlaylistId(url);
if (listId == null) return null;
// 영상은 받지 않고 목록만 빠르게 읽는다
final r = await Process.run(
ytdlp,
[
'--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8',
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
...cookieArgs(),
'https://www.youtube.com/playlist?list=$listId',
],
environment: _env,
stdoutEncoding: utf8,
stderrEncoding: utf8,
);
if (r.exitCode != 0) {
throw ProcessException(
ytdlp, [url], '재생목록을 읽을 수 없습니다: ${friendlyYtDlpError((r.stderr as String).trim())}', r.exitCode);
}
final j = jsonDecode(r.stdout as String) as Map<String, dynamic>;
final entries = <PlaylistEntry>[];
for (final e in (j['entries'] as List? ?? const [])) {
final m = e as Map;
final id = m['id'] as String?;
final title = (m['title'] as String?) ?? id ?? '';
// 삭제 · 비공개 영상은 건너뜀
if (id == null || title == '[Deleted video]' || title == '[Private video]') continue;
entries.add(PlaylistEntry('https://www.youtube.com/watch?v=$id', title));
}
return ((j['title'] as String?) ?? listId, entries);
}
static String? _existing(String path) => File(path).existsSync() ? path : null;
@override
DownloadKind get kind => DownloadKind.video;
List<String> _args(DownloadTask t) => [
'--newline', '--no-colors', '--encoding', 'utf-8',
'--no-playlist', '--windows-filenames', '--no-mtime',
'--progress-template',
'download:[JJ]%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s',
'--print', 'before_dl:[JJT]%(title)s',
'--print', 'after_move:[JJF]%(filepath)s',
'-P', t.dir,
'-o', '%(title).150B [%(id)s].%(ext)s',
...formatArgs(),
...cookieArgs(),
if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!],
if (deno != null) ...['--js-runtimes', 'deno:$deno'],
t.source,
];
@override
Future<void> start(DownloadTask t, void Function() changed) async {
if (_procs.containsKey(t.id)) return;
await Directory(t.dir).create(recursive: true);
t
..state = DownloadState.downloading
..error = null;
changed();
final Process proc;
try {
proc = await Process.start(ytdlp, _args(t), environment: _env);
} on ProcessException catch (e) {
t
..state = DownloadState.failed
..error = 'yt-dlp 를 실행할 수 없습니다: ${e.message}';
changed();
return;
}
_procs[t.id] = proc;
final errTail = <String>[];
void onLine(String line) {
final prog = parseYtDlpProgress(line);
if (prog != null) {
t
..progress = prog.percent ?? t.progress
..speed = prog.speed
..eta = prog.eta;
changed();
} else if (line.startsWith('[JJT]')) {
t.title = line.substring(5).trim();
changed();
} else if (line.startsWith('[JJF]')) {
t.files.add(line.substring(5).trim());
} else if (line.startsWith('[download] Destination: ')) {
t.files.add(line.substring('[download] Destination: '.length).trim());
} else if (line.startsWith('[Merger] Merging formats into "')) {
t.files.add(line.substring('[Merger] Merging formats into "'.length).replaceFirst(RegExp(r'"$'), ''));
}
}
final outDone = proc.stdout
.transform(const Utf8Decoder(allowMalformed: true))
.transform(const LineSplitter())
.forEach(onLine);
final errDone = proc.stderr
.transform(const Utf8Decoder(allowMalformed: true))
.transform(const LineSplitter())
.forEach((l) {
errTail.add(l);
if (errTail.length > 8) errTail.removeAt(0);
});
final code = await proc.exitCode;
await Future.wait([outDone, errDone]);
_procs.remove(t.id);
if (_stopping.remove(t.id)) return; // 일시정지·취소는 호출한 쪽에서 상태 설정
if (code == 0) {
t
..state = DownloadState.done
..progress = 1
..speed = ''
..eta = '';
} else {
t
..state = DownloadState.failed
..error = friendlyYtDlpError(
errTail.where((l) => l.contains('ERROR')).join('\n').ifEmpty(errTail.join('\n')));
}
changed();
}
Future<void> _stop(DownloadTask t) async {
final proc = _procs[t.id];
if (proc == null) return;
_stopping.add(t.id);
// yt-dlp 가 띄운 ffmpeg 까지 함께 종료
if (Platform.isWindows) {
await Process.run('taskkill', ['/PID', '${proc.pid}', '/T', '/F']);
} else {
proc.kill();
}
await proc.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1);
}
@override
Future<void> pause(DownloadTask t) async {
await _stop(t);
t
..state = DownloadState.paused
..speed = ''
..eta = '';
}
@override
Future<void> cancel(DownloadTask t) async {
await _stop(t);
t.state = DownloadState.cancelled;
for (final f in t.files) {
for (final path in [f, '$f.part', '$f.ytdl']) {
try {
final file = File(path);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
// 조각 파일 (영상.f137.mp4.part 등)
try {
final dir = Directory(t.dir);
if (await dir.exists()) {
await for (final e in dir.list()) {
final n = p.basename(e.path);
if (e is File && (n.endsWith('.part') || n.endsWith('.ytdl')) &&
t.files.any((f) => n.startsWith(p.basenameWithoutExtension(f)))) {
await e.delete();
}
}
}
} catch (_) {}
}
@override
Future<void> shutdown() async {
for (final id in _procs.keys.toList()) {
final proc = _procs[id]!;
_stopping.add(id);
if (Platform.isWindows) {
await Process.run('taskkill', ['/PID', '${proc.pid}', '/T', '/F']);
} else {
proc.kill();
}
}
_procs.clear();
}
}
extension on String {
String ifEmpty(String other) => isEmpty ? other : this;
}