From cc6974fc30970956dbed4eb5c8dbcb606d6ea568 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:29:01 +0900 Subject: [PATCH] =?UTF-8?q?v1.0.1:=20=EC=9E=90=EB=8F=99=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8,=20YouTube=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=ED=98=95=EC=8B=9D=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EC=A7=88,=20=EB=8F=99=EC=8B=9C=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub 최신 Release 확인 · SHA256 검증 · 종료 후 교체 · 다시 시작 (받은 파일 · 모델 · 설정 보존) - YouTube 재생목록 전체 받기, 형식(MP4 · WebM · MP3 · M4A) · 화질 · 쿠키 설정 - 동시 다운로드 수 · 동시 MKV 변환 수 (대기열) - 다운로드 진행 막대에 퍼센트, 작업 기록 창 숨기기 - AV1 하드웨어 디코딩 미지원 시 거짓 "Could not open codec" 오류 표시 수정 Co-Authored-By: Claude Opus 5.5 --- integration_test/av1_check_test.dart | 33 +++ lib/app/app_controller.dart | 21 +- lib/app/download_manager.dart | 84 +++++- lib/app/settings.dart | 50 ++++ lib/core/app_update.dart | 88 +++++++ lib/core/download_detect.dart | 77 ++++++ lib/main.dart | 14 +- .../common/media_kit_full_player.dart | 19 +- lib/platform/common/media_kit_player.dart | 3 +- lib/platform/windows/aria2_backend.dart | 3 + lib/platform/windows/github_updater.dart | 144 +++++++++++ lib/platform/windows/process_media_tool.dart | 25 +- lib/platform/windows/ytdlp_backend.dart | 63 ++++- lib/services/downloader.dart | 13 + lib/services/platform_services.dart | 20 +- lib/services/updater.dart | 31 +++ lib/ui/downloads_page.dart | 64 ++++- lib/ui/home_page.dart | 90 ++++++- lib/ui/player_page.dart | 9 +- lib/ui/settings_page.dart | 170 ++++++++++++ lib/ui/update_dialog.dart | 147 +++++++++++ pubspec.lock | 4 +- pubspec.yaml | 4 +- test/download_test.dart | 6 + test/downloads_ui_test.dart | 11 +- test/queue_test.dart | 242 ++++++++++++++++++ test/update_test.dart | 142 ++++++++++ test/update_ui_test.dart | 109 ++++++++ 28 files changed, 1624 insertions(+), 62 deletions(-) create mode 100644 integration_test/av1_check_test.dart create mode 100644 lib/core/app_update.dart create mode 100644 lib/platform/windows/github_updater.dart create mode 100644 lib/services/updater.dart create mode 100644 lib/ui/update_dialog.dart create mode 100644 test/queue_test.dart create mode 100644 test/update_test.dart create mode 100644 test/update_ui_test.dart diff --git a/integration_test/av1_check_test.dart b/integration_test/av1_check_test.dart new file mode 100644 index 0000000..6be9bf0 --- /dev/null +++ b/integration_test/av1_check_test.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/platform/common/media_kit_full_player.dart'; +import 'package:jj_mkvmaker/platform/common/media_kit_player.dart'; + +/// AV1 (하드웨어 디코딩 미지원 그래픽카드) 파일: 오류 알림 없이 재생되는지 (JJ_AV1_FILE) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('AV1 재생 · 오류 알림 없음', (tester) async { + final file = Platform.environment['JJ_AV1_FILE']; + if (file == null || !File(file).existsSync()) return; + MediaKitPreviewPlayer.ensureInitialized(); + final pl = MediaKitFullPlayer(); + final errors = []; + pl.errors.listen(errors.add); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: pl.buildView()))); + await tester.runAsync(() => pl.open([file])); + for (var i = 0; i < 60; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + final s = pl.state.value; + // ignore: avoid_print + print('RESULT 위치 ${s.position.inMilliseconds}ms 음성트랙 ${s.audioTracks.length} 오류 $errors'); + expect(s.position, greaterThan(const Duration(seconds: 4))); + expect(errors, isEmpty); + await tester.runAsync(() => pl.dispose()); + }); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 0f840b4..5ab405a 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -61,6 +61,9 @@ class AppController extends ChangeNotifier { AppController(this.services, {this.settingsStore}); + /// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용 + Future Function()? confirmQuit; + /// 설정 변경 후 저장·적용 Future updateSettings(void Function(AppSettings s) change) async { change(settings); @@ -650,12 +653,21 @@ class AppController extends ChangeNotifier { Future buildAll() async { if (busy || ffmpegVersion == null) return; busy = true; + _buildCancelled = false; notifyListeners(); try { - for (final v in videos) { - if (v.status == JobStatus.done) continue; - await _build(v); + // 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다. + final queue = videos.where((v) => v.status != JobStatus.done).toList(); + final limit = settings.maxParallelJobs <= 0 ? queue.length : settings.maxParallelJobs; + if (queue.length > 1) _log('MKV 만들기: ${queue.length}개, 동시에 ${limit.clamp(1, queue.length)}개씩'); + var next = 0; + Future worker() async { + while (!_buildCancelled && next < queue.length) { + await _build(queue[next++]); + } } + + await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); final ok = videos.where((v) => v.status == JobStatus.done).length; _log('완료: 성공 $ok / 전체 ${videos.length}'); } finally { @@ -701,7 +713,10 @@ class AppController extends ChangeNotifier { notifyListeners(); } + bool _buildCancelled = false; + void cancel() { + _buildCancelled = true; _aiCancelled = true; _translator?.cancel(); _tool.cancel(); diff --git a/lib/app/download_manager.dart b/lib/app/download_manager.dart index ec02673..d466234 100644 --- a/lib/app/download_manager.dart +++ b/lib/app/download_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import '../core/download_detect.dart'; import '../services/downloader.dart'; @@ -100,11 +101,81 @@ class DownloadManager extends ChangeNotifier { ); tasks.insert(0, t); _added.add(t); + // YouTube 재생목록: 목록을 읽어 영상마다 한 줄씩 바꿔 넣는다 + if (link.kind == DownloadKind.video && s.ytExpandPlaylists && youtubePlaylistId(link.url) != null) { + t + ..expanding = true + ..title = '재생목록 불러오는 중… ${link.url}'; + notifyListeners(); + unawaited(_expand(t, backend)); + return t; + } notifyListeners(); - unawaited(backend.start(t, notifyListeners)); + _pump(); return t; } + Future _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 = []; + 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'; + notifyListeners(); + } + } + + /// 상태가 바뀔 때: 화면 갱신 + 대기 중인 다음 작업 시작 + void _changed() { + 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 get _sel => tasks.where((t) => selected.contains(t.id)); void toggle(DownloadTask t) { @@ -130,17 +201,20 @@ class DownloadManager extends ChangeNotifier { await backends[t.kind]!.pause(t); } } - notifyListeners(); + _changed(); } + /// 다시 받기: 대기열에 넣고 차례가 되면 시작 Future resume(Iterable 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 작업은 새로 추가 - unawaited(backends[t.kind]!.start(t, notifyListeners)); + t + ..state = DownloadState.queued + ..error = null; } } - notifyListeners(); + _changed(); } /// 중지하고 받던 파일 삭제 (목록에는 '취소됨' 으로 남김) @@ -148,7 +222,7 @@ class DownloadManager extends ChangeNotifier { for (final t in list.toList()) { if (t.unfinished) await backends[t.kind]!.cancel(t); } - notifyListeners(); + _changed(); } /// 목록에서 삭제 (진행 중이면 취소 후 삭제) diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 0ec02d1..f44425a 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; @@ -32,6 +33,33 @@ class AppSettings { String openSubtitlesUser = ''; String openSubtitlesPassword = ''; + /// YouTube 받을 형식 · 화질 (기본: MP4 최고 화질) + YtContainer ytContainer = YtContainer.mp4; + YtQuality ytQuality = YtQuality.best; + + /// 재생목록 주소면 목록 전체를 받기 + bool ytExpandPlaylists = true; + + /// YouTube 로봇 확인 대응: 쿠키를 가져올 브라우저 (빈 값 = 사용 안 함) 또는 cookies.txt 경로 + String ytCookiesBrowser = ''; + String ytCookiesFile = ''; + + /// 동시에 받는 다운로드 수 (0 = 무제한) + int maxParallelDownloads = 3; + + /// 동시에 만드는 MKV 수 (0 = 무제한) + int maxParallelJobs = 5; + + /// 메인 화면 아래 작업 기록 창 보이기 + bool showLog = true; + + /// 시작할 때 새 버전 확인 (하루 한 번) + bool autoCheckUpdates = true; + String lastUpdateCheck = ''; + + /// "이 버전 건너뛰기" 한 버전 + String skippedVersion = ''; + /// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈) PlaylistMode playlistMode = PlaylistMode.series; @@ -58,6 +86,17 @@ class AppSettings { 'openSubtitlesKey': openSubtitlesKey, 'openSubtitlesUser': openSubtitlesUser, 'openSubtitlesPassword': openSubtitlesPassword, + 'ytContainer': ytContainer.name, + 'ytQuality': ytQuality.name, + 'ytExpandPlaylists': ytExpandPlaylists, + 'ytCookiesBrowser': ytCookiesBrowser, + 'ytCookiesFile': ytCookiesFile, + 'maxParallelDownloads': maxParallelDownloads, + 'maxParallelJobs': maxParallelJobs, + 'showLog': showLog, + 'autoCheckUpdates': autoCheckUpdates, + 'lastUpdateCheck': lastUpdateCheck, + 'skippedVersion': skippedVersion, 'playlistMode': playlistMode.name, 'externalPlayers': externalPlayers, 'encode': { @@ -84,6 +123,17 @@ class AppSettings { ..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? '' ..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? '' ..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? '' + ..ytContainer = pick(YtContainer.values, j['ytContainer'], YtContainer.mp4) + ..ytQuality = pick(YtQuality.values, j['ytQuality'], YtQuality.best) + ..ytExpandPlaylists = j['ytExpandPlaylists'] as bool? ?? true + ..ytCookiesBrowser = j['ytCookiesBrowser'] as String? ?? '' + ..ytCookiesFile = j['ytCookiesFile'] as String? ?? '' + ..maxParallelDownloads = (j['maxParallelDownloads'] as num?)?.toInt() ?? 3 + ..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5 + ..showLog = j['showLog'] as bool? ?? true + ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true + ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' + ..skippedVersion = j['skippedVersion'] as String? ?? '' ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast() ..encode = EncodeSettings( diff --git a/lib/core/app_update.dart b/lib/core/app_update.dart new file mode 100644 index 0000000..ee11148 --- /dev/null +++ b/lib/core/app_update.dart @@ -0,0 +1,88 @@ +/// GitHub 저장소 (업데이트 확인) +const updateRepo = 'RyuJongJin/JJ_MKVMaker'; + +/// "v1.2.3" · "1.2.3+4" → [1, 2, 3] (숫자가 아닌 부분 무시) +List parseVersion(String v) { + final core = v.trim().replaceFirst(RegExp(r'^[vV]'), '').split(RegExp(r'[+\-\s]')).first; + return [for (final part in core.split('.')) int.tryParse(part) ?? 0]; +} + +/// a < b 면 음수, 같으면 0, a > b 면 양수 +int compareVersions(String a, String b) { + final x = parseVersion(a), y = parseVersion(b); + for (var i = 0; i < x.length || i < y.length; i++) { + final d = (i < x.length ? x[i] : 0) - (i < y.length ? y[i] : 0); + if (d != 0) return d; + } + return 0; +} + +/// GitHub Release 정보 +class ReleaseInfo { + final String version; // 1.0.1 + final String tag; // v1.0.1 + final String name; + final String notes; + final String pageUrl; + + /// Windows zip (없으면 null → 페이지만 안내) + final String? zipUrl; + final String? zipName; + final int zipSize; + + /// GitHub 가 계산한 zip 의 SHA256 (소문자 16진수). 없으면 null + final String? sha256; + + const ReleaseInfo({ + required this.version, + required this.tag, + required this.name, + required this.notes, + required this.pageUrl, + this.zipUrl, + this.zipName, + this.zipSize = 0, + this.sha256, + }); + + bool isNewerThan(String current) => compareVersions(version, current) > 0; +} + +/// GitHub API `releases/latest` 응답 → ReleaseInfo (초안 · 시험판은 null) +ReleaseInfo? parseLatestRelease(Map j) { + if (j['draft'] == true || j['prerelease'] == true) return null; + final tag = j['tag_name'] as String?; + if (tag == null) return null; + Map? zip; + for (final a in (j['assets'] as List? ?? const [])) { + final m = a as Map; + if (RegExp(r'win64\.zip$', caseSensitive: false).hasMatch(m['name'] as String? ?? '')) { + zip = m; + break; + } + } + String? sha = (zip?['digest'] as String?)?.toLowerCase(); + if (sha != null && sha.startsWith('sha256:')) { + sha = sha.substring(7); + } else { + // digest 가 없으면 설명에 적어 둔 SHA256 (64자리 16진수) 사용 + sha = RegExp(r'\b([0-9a-fA-F]{64})\b').firstMatch(j['body'] as String? ?? '')?.group(1)?.toLowerCase(); + } + return ReleaseInfo( + version: parseVersion(tag).join('.'), + tag: tag, + name: j['name'] as String? ?? tag, + notes: j['body'] as String? ?? '', + pageUrl: j['html_url'] as String? ?? 'https://github.com/$updateRepo/releases', + zipUrl: zip?['browser_download_url'] as String?, + zipName: zip?['name'] as String?, + zipSize: (zip?['size'] as num?)?.toInt() ?? 0, + sha256: sha, + ); +} + +/// 자동 확인은 하루에 한 번 +bool updateCheckDue(String lastCheckIso, DateTime now) { + final last = DateTime.tryParse(lastCheckIso); + return last == null || now.difference(last) >= const Duration(hours: 20); +} diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart index a21e0ca..39a0a13 100644 --- a/lib/core/download_detect.dart +++ b/lib/core/download_detect.dart @@ -64,6 +64,83 @@ YtDlpProgress? parseYtDlpProgress(String line) { return YtDlpProgress(pct == null ? null : pct / 100, clean(part(1)), clean(part(2))); } +/// YouTube 재생목록 주소인지 (재생목록 ID 반환). +/// 믹스(RD…) · 좋아요(LL) · 나중에 볼 동영상(WL) 은 제외 (끝없는 목록 · 로그인 필요) +String? youtubePlaylistId(String url) { + final m = RegExp(r'[?&]list=([\w-]+)').firstMatch(url); + if (m == null) return null; + final id = m[1]!; + if (id.startsWith('RD') || id == 'LL' || id == 'WL') return null; + return id; +} + +/// 폴더 이름으로 쓸 수 없는 글자 제거 +String safeFolderName(String s) { + final t = s.replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1F]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim(); + final cut = t.length > 80 ? t.substring(0, 80).trim() : t; + return cut.replaceAll(RegExp(r'[. ]+$'), ''); +} + +/// YouTube 받을 형식 +enum YtContainer { + mp4('MP4 (동영상)'), + webm('WebM (동영상)'), + mp3('음성만 MP3'), + m4a('음성만 M4A'); + + final String label; + const YtContainer(this.label); + + bool get audioOnly => this == mp3 || this == m4a; +} + +/// YouTube 화질 (세로 픽셀 이하 중 가장 좋은 것) +enum YtQuality { + best('최고 화질', null), + p2160('4K (2160p)', 2160), + p1440('1440p', 1440), + p1080('1080p', 1080), + p720('720p', 720), + p480('480p', 480), + p360('360p', 360); + + final String label; + final int? height; + const YtQuality(this.label, this.height); +} + +/// yt-dlp 형식 인수 +List ytDlpFormatArgs(YtContainer c, YtQuality q) { + final res = q.height == null ? '' : 'res:${q.height},'; + return switch (c) { + YtContainer.mp4 => ['-S', '${res}ext:mp4:m4a', '--merge-output-format', 'mp4', '--remux-video', 'mp4'], + YtContainer.webm => ['-S', '${res}ext:webm:webm', '--merge-output-format', 'webm'], + YtContainer.mp3 => ['-f', 'ba/b', '-x', '--audio-format', 'mp3', '--audio-quality', '0'], + YtContainer.m4a => ['-f', 'ba[ext=m4a]/ba/b', '-x', '--audio-format', 'm4a'], + }; +} + +/// yt-dlp 쿠키 인수 (YouTube 로봇 확인 대응). cookies.txt 가 있으면 우선. +List ytDlpCookieArgs({String browser = '', String file = ''}) { + if (file.trim().isNotEmpty) return ['--cookies', file.trim()]; + if (browser.trim().isNotEmpty) return ['--cookies-from-browser', browser.trim()]; + return const []; +} + +/// yt-dlp 오류를 알기 쉬운 안내로 +String friendlyYtDlpError(String err) { + if (err.contains('Sign in to confirm') || err.contains('not a bot')) { + return 'YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 ' + '브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.'; + } + if (err.contains('Private video') || err.contains('members-only')) return '비공개 · 회원 전용 영상입니다.'; + if (err.contains('Video unavailable')) return '볼 수 없는 영상입니다 (삭제 · 지역 제한).'; + if (err.contains('could not find') && err.contains('cookies')) { + return '브라우저 쿠키를 읽지 못했습니다. 브라우저를 닫고 다시 시도하거나 cookies.txt 를 지정하세요.'; + } + return err; +} + /// 바이트 → 읽기 쉬운 크기 String formatBytes(num b) { if (b < 1024) return '${b.round()}B'; diff --git a/lib/main.dart b/lib/main.dart index 5954aa8..efd5c82 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'ui/downloads_page.dart'; import 'ui/home_page.dart'; import 'ui/player_page.dart'; import 'ui/setup_dialog.dart'; +import 'ui/update_dialog.dart'; import 'ui/theme.dart'; Future main(List args) async { @@ -31,7 +32,7 @@ Future main(List args) async { await controller.init(); final downloads = DownloadManager( - backends: services.createDownloadBackends?.call() ?? const [], + backends: services.createDownloadBackends?.call(() => controller.settings) ?? const [], settings: () => controller.settings, ); final navigatorKey = GlobalKey(); @@ -85,10 +86,15 @@ Future main(List args) async { } }); - // 처음 화면이 뜬 뒤 필수 프로그램 점검 (없으면 물어보고 설치) - WidgetsBinding.instance.addPostFrameCallback((_) { + controller.confirmQuit = confirmQuit; + + // 처음 화면이 뜬 뒤: 필수 프로그램 점검 (없으면 물어보고 설치) → 새 버전 확인 (하루 한 번) + WidgetsBinding.instance.addPostFrameCallback((_) async { final ctx = navigatorKey.currentContext; - if (ctx != null) checkRequiredTools(ctx, services.shell); + if (ctx == null) return; + await checkRequiredTools(ctx, services.shell); + final ctx2 = navigatorKey.currentContext; + if (ctx2 != null && ctx2.mounted) await checkForUpdate(ctx2, controller); }); runApp(JjCapCutApp( diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index aacbab0..f463dd1 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -7,10 +7,23 @@ import 'package:path/path.dart' as p; import '../../services/media_player.dart'; +/// mpv 가 오류 수준으로 남기지만 재생에는 지장이 없는 메시지. +/// +/// 예) AV1 하드웨어 디코딩을 지원하지 않는 그래픽카드에서 mpv 는 d3d11va · dxva2 등을 차례로 +/// 시도하다 실패하며 "Could not open codec." 을 남긴 뒤 소프트웨어(libdav1d)로 정상 재생한다. +bool isNonFatalMpvMessage(String m) => + m.contains('Could not open codec') || + m.contains('hardware decoding') || + m.contains('Could not create device'); + /// mpv(media_kit) 동영상 플레이어. Windows·Android 공용. class MediaKitFullPlayer implements MediaPlayer { final mk.Player _p = mk.Player(); - late final VideoController _video = VideoController(_p); + late final VideoController _video = VideoController(_p, + configuration: VideoControllerConfiguration(enableHardwareAcceleration: hardwareAcceleration)); + + /// 하드웨어 디코딩 사용 (지원하지 않는 코덱은 mpv 가 소프트웨어로 대체) + final bool hardwareAcceleration; final _subs = >[]; final _errors = StreamController.broadcast(); List _files = []; @@ -21,7 +34,7 @@ class MediaKitFullPlayer implements MediaPlayer { @override final ValueNotifier state = ValueNotifier(const PlayerState()); - MediaKitFullPlayer() { + MediaKitFullPlayer({this.hardwareAcceleration = true}) { final s = _p.stream; _subs ..add(s.position.listen((_) => _emit())) @@ -38,7 +51,7 @@ class MediaKitFullPlayer implements MediaPlayer { _externalSub = null; // 다음 파일로 넘어가면 외부 자막은 풀림 _emit(); })) - ..add(s.error.listen(_errors.add)); + ..add(s.error.where((m) => !isNonFatalMpvMessage(m)).listen(_errors.add)); } static String _label(String? title, String? lang, String id) { diff --git a/lib/platform/common/media_kit_player.dart b/lib/platform/common/media_kit_player.dart index d96d8e9..3351d74 100644 --- a/lib/platform/common/media_kit_player.dart +++ b/lib/platform/common/media_kit_player.dart @@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import '../../services/preview_player.dart'; +import 'media_kit_full_player.dart' show isNonFatalMpvMessage; /// media_kit(libmpv) 기반 재생기. Windows·Android 공용. class MediaKitPreviewPlayer implements PreviewPlayer { @@ -31,7 +32,7 @@ class MediaKitPreviewPlayer implements PreviewPlayer { @override Stream get playingStream => _player.stream.playing; @override - Stream get errorStream => _player.stream.error; + Stream get errorStream => _player.stream.error.where((m) => !isNonFatalMpvMessage(m)); @override Duration get position => _player.state.position; diff --git a/lib/platform/windows/aria2_backend.dart b/lib/platform/windows/aria2_backend.dart index d7529f9..6e649dc 100644 --- a/lib/platform/windows/aria2_backend.dart +++ b/lib/platform/windows/aria2_backend.dart @@ -27,6 +27,9 @@ class Aria2Backend implements DownloadBackend { @override DownloadKind get kind => DownloadKind.torrent; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + static String _randomToken() { final r = math.Random.secure(); return List.generate(24, (_) => r.nextInt(36).toRadixString(36)).join(); diff --git a/lib/platform/windows/github_updater.dart b/lib/platform/windows/github_updater.dart new file mode 100644 index 0000000..4000e16 --- /dev/null +++ b/lib/platform/windows/github_updater.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; + +import '../../core/app_update.dart'; +import '../../services/updater.dart'; + +/// GitHub Release 로 업데이트 (Windows) +/// +/// 1. releases/latest 조회 → 2. zip 받기 · SHA256 확인 · 압축 풀기 +/// 3. 업데이트 스크립트 예약: 앱이 끝나면 파일 교체 (받은 파일 · 모델 · 설정은 그대로) → 새 버전 실행 +class GitHubUpdater implements Updater { + final String apiBase; + final String appDir; + final String? versionOverride; + final HttpClient _http = HttpClient() + ..userAgent = 'JJ_MKVMaker-updater' + ..connectionTimeout = const Duration(seconds: 20); + + GitHubUpdater({ + this.apiBase = 'https://api.github.com', + String? appDir, + this.versionOverride, + }) : appDir = appDir ?? p.dirname(Platform.resolvedExecutable); + + @override + Future currentVersion() async => + versionOverride ?? (await PackageInfo.fromPlatform()).version; + + @override + Future latest() async { + final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases/latest')); + req.headers.set('Accept', 'application/vnd.github+json'); + final res = await req.close(); + final body = await res.transform(utf8.decoder).join(); + if (res.statusCode == 404) return null; + if (res.statusCode != 200) { + throw UpdateException('최신 버전을 확인할 수 없습니다 (${res.statusCode})'); + } + return parseLatestRelease(jsonDecode(body) as Map); + } + + @override + Future canInstall() async { + try { + final probe = File(p.join(appDir, '.jj_update_test')); + await probe.writeAsString('x'); + await probe.delete(); + return true; + } catch (_) { + return false; + } + } + + @override + Future download(ReleaseInfo r, void Function(double progress) onProgress) async { + final url = r.zipUrl; + if (url == null) throw const UpdateException('이 버전에는 Windows 용 zip 이 없습니다.'); + final work = Directory(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update_${r.version}')); + if (await work.exists()) await work.delete(recursive: true); + await work.create(recursive: true); + final zip = File(p.join(work.path, r.zipName ?? 'update.zip')); + + // 받기 + final req = await _http.getUrl(Uri.parse(url)); + final res = await req.close(); + if (res.statusCode != 200) throw UpdateException('내려받기 실패 (${res.statusCode})'); + final total = res.contentLength > 0 ? res.contentLength : r.zipSize; + final sink = zip.openWrite(); + var got = 0; + await for (final chunk in res) { + sink.add(chunk); + got += chunk.length; + if (total > 0) onProgress((got / total).clamp(0.0, 0.99)); + } + await sink.close(); + + // 검증: GitHub 가 알려 준 SHA256 과 비교 + if (r.sha256 != null) { + final hash = (await sha256.bind(zip.openRead()).first).toString(); + if (hash != r.sha256) { + await work.delete(recursive: true); + throw UpdateException('받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.'); + } + } + + // 압축 풀기 (Windows 내장 tar) + final out = Directory(p.join(work.path, 'extract')); + await out.create(); + final t = await Process.run('tar', ['-xf', zip.path, '-C', out.path]); + if (t.exitCode != 0) throw UpdateException('압축을 풀 수 없습니다: ${t.stderr}'); + await zip.delete(); + final exeName = p.basename(Platform.resolvedExecutable).toLowerCase(); + final exe = await out + .list(recursive: true) + .firstWhere((e) => e is File && p.basename(e.path).toLowerCase() == 'jj_mkvmaker.exe' || + e is File && p.basename(e.path).toLowerCase() == exeName, + orElse: () => throw const UpdateException('압축 파일 안에 프로그램이 없습니다.')); + onProgress(1); + return p.dirname(exe.path); + } + + /// 파일 교체 스크립트 (영문만: Windows PowerShell 5.1 이 BOM 없는 UTF-8 을 잘못 읽는 문제 방지) + static const script = r''' +param([int]$ParentPid, [string]$Source, [string]$Target, [string]$Exe, [switch]$NoRestart) +$ErrorActionPreference = 'Continue' +try { Wait-Process -Id $ParentPid -Timeout 90 -ErrorAction Stop } catch {} +Start-Sleep -Milliseconds 800 +$log = Join-Path $env:TEMP 'jj_mkvmaker_update.log' +# keep downloads, models and user data +robocopy $Source $Target /E /R:10 /W:1 /XD jj_yt-dlp jj_aria2 models /NFL /NDL /NP /LOG:$log | Out-Null +if ($LASTEXITCODE -ge 8) { Start-Process notepad.exe $log; exit 1 } +if (-not $NoRestart) { Start-Process -FilePath $Exe } +exit 0 +'''; + + /// 스크립트 실행. [waitPid] 가 끝나기를 기다렸다가 교체한다. + Future runInstallScript(String source, {required int waitPid, bool restart = true}) async { + final ps1 = File(p.join(Directory.systemTemp.path, 'jj_mkvmaker_update.ps1')); + await ps1.writeAsString(script); + return Process.start( + 'powershell.exe', + [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1.path, + '-ParentPid', '$waitPid', '-Source', source, '-Target', appDir, + '-Exe', p.join(appDir, p.basename(Platform.resolvedExecutable)), + if (!restart) '-NoRestart', + ], + mode: restart ? ProcessStartMode.detached : ProcessStartMode.normal, + ); + } + + @override + Future scheduleInstall(String extractedDir) async { + await runInstallScript(extractedDir, waitPid: pid); + } + + @override + Future openPage(ReleaseInfo r) => + Process.start('explorer.exe', [r.pageUrl], mode: ProcessStartMode.detached); +} diff --git a/lib/platform/windows/process_media_tool.dart b/lib/platform/windows/process_media_tool.dart index ae03782..43100bc 100644 --- a/lib/platform/windows/process_media_tool.dart +++ b/lib/platform/windows/process_media_tool.dart @@ -11,8 +11,11 @@ import '../../services/media_tool.dart'; class ProcessMediaTool implements MediaTool { final String ffmpeg; final String ffprobe; - Process? _running; - bool _cancelled = false; + /// 실행 중인 ffmpeg (여러 개 동시 실행 가능) + final Set _running = {}; + + /// 취소로 종료시킨 프로세스 + final Set _killed = {}; ProcessMediaTool(this.ffmpeg, this.ffprobe); @@ -105,14 +108,13 @@ class ProcessMediaTool implements MediaTool { Duration? duration, ProgressCallback? onProgress, }) async { - _cancelled = false; final Process proc; try { proc = await Process.start(ffmpeg, args); } on ProcessException catch (e) { throw MediaToolException('ffmpeg 를 실행할 수 없습니다: ${e.message}'); } - _running = proc; + _running.add(proc); final totalUs = duration?.inMicroseconds ?? 0; final stdoutDone = proc.stdout @@ -138,22 +140,23 @@ class ProcessMediaTool implements MediaTool { final code = await proc.exitCode; await Future.wait([stdoutDone, stderrDone]); - _running = null; + _running.remove(proc); - if (_cancelled) throw const MediaToolException('사용자가 취소했습니다.'); + if (_killed.remove(proc)) throw const MediaToolException('사용자가 취소했습니다.'); if (code != 0) { throw MediaToolException('ffmpeg 오류 (코드 $code)\n${errTail.join('\n')}'); } onProgress?.call(1.0); } + /// 실행 중인 ffmpeg 모두 중단 @override void cancel() { - final proc = _running; - if (proc == null) return; - _cancelled = true; - // 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중) - proc.kill(); + for (final proc in _running.toList()) { + _killed.add(proc); + // 'q' 로 정상 종료를 요청하지 않고 즉시 중단 (-nostdin 사용 중) + proc.kill(); + } } } diff --git a/lib/platform/windows/ytdlp_backend.dart b/lib/platform/windows/ytdlp_backend.dart index 5ac23ef..6e35ed0 100644 --- a/lib/platform/windows/ytdlp_backend.dart +++ b/lib/platform/windows/ytdlp_backend.dart @@ -33,9 +33,58 @@ class YtDlpBackend implements DownloadBackend { final _procs = {}; final _stopping = {}; - YtDlpBackend({String? ytdlp, this.ffmpegDir, String? deno}) - : ytdlp = ytdlp ?? locateTool('yt-dlp'), - deno = deno ?? _existing(locateTool('deno')); + /// 형식 · 화질 인수 (환경 설정에서 읽음). 없으면 MP4 최고 화질. + final List Function() formatArgs; + + /// 쿠키 인수 (YouTube 로봇 확인 대응, 환경 설정에서 읽음) + final List Function() cookieArgs; + + YtDlpBackend({ + String? ytdlp, + this.ffmpegDir, + String? deno, + List Function()? formatArgs, + List 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)?> 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; + final entries = []; + 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; @@ -51,6 +100,8 @@ class YtDlpBackend implements DownloadBackend { '--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, @@ -67,8 +118,7 @@ class YtDlpBackend implements DownloadBackend { final Process proc; try { - proc = await Process.start(ytdlp, _args(t), - environment: {'PYTHONIOENCODING': 'utf-8', 'PYTHONUTF8': '1'}); + proc = await Process.start(ytdlp, _args(t), environment: _env); } on ProcessException catch (e) { t ..state = DownloadState.failed @@ -124,7 +174,8 @@ class YtDlpBackend implements DownloadBackend { } else { t ..state = DownloadState.failed - ..error = errTail.where((l) => l.contains('ERROR')).join('\n').ifEmpty(errTail.join('\n')); + ..error = friendlyYtDlpError( + errTail.where((l) => l.contains('ERROR')).join('\n').ifEmpty(errTail.join('\n'))); } changed(); } diff --git a/lib/services/downloader.dart b/lib/services/downloader.dart index 3e73506..14d35d8 100644 --- a/lib/services/downloader.dart +++ b/lib/services/downloader.dart @@ -30,14 +30,27 @@ class DownloadTask { bool get unfinished => state == DownloadState.queued || state == DownloadState.downloading || state == DownloadState.paused; + + /// 재생목록을 불러오는 중인 임시 항목 (다 불러오면 영상별 항목으로 바뀜) + bool expanding = false; } /// 다운로드 엔진 경계. /// Windows: yt-dlp.exe 프로세스 / aria2c.exe RPC (platform/windows) /// Android: (이식 시) youtubedl-android · aria2 안드로이드 빌드 +/// 재생목록 안의 항목 +class PlaylistEntry { + final String url; + final String title; + const PlaylistEntry(this.url, this.title); +} + abstract class DownloadBackend { DownloadKind get kind; + /// 재생목록이면 (목록 이름, 항목들). 재생목록이 아니거나 지원하지 않으면 null. + Future<(String, List)?> expandPlaylist(String url); + /// 시작 (또는 일시정지 후 다시 시작). 상태가 바뀔 때마다 [changed] 호출. Future start(DownloadTask t, void Function() changed); diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index e190ef4..a59976b 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -5,12 +5,14 @@ import 'package:path/path.dart' as p; import '../platform/common/media_kit_full_player.dart'; import '../platform/common/media_kit_player.dart'; import '../app/settings.dart'; +import '../core/download_detect.dart'; import '../platform/common/nllb_translator.dart'; import '../platform/common/opensubtitles_provider.dart'; import '../platform/common/whisper_recognizer.dart'; import '../platform/windows/aria2_backend.dart'; import '../platform/windows/desktop_shell.dart'; import '../platform/windows/desktop_storage_service.dart'; +import '../platform/windows/github_updater.dart'; import '../platform/windows/process_media_tool.dart'; import '../platform/windows/ytdlp_backend.dart'; import 'ai_services.dart'; @@ -22,6 +24,7 @@ import 'model_store.dart'; import 'preview_player.dart'; import 'storage_service.dart'; import 'subtitle_provider.dart'; +import 'updater.dart'; /// 플랫폼별 구현을 한 곳에서 고른다. /// Android 이식 시 이 파일에 분기만 추가하면 된다. @@ -40,12 +43,15 @@ class PlatformServices { final Translator Function()? createTranslator; final ModelStore models; - /// 다운로드 엔진 (yt-dlp, aria2) - final List Function()? createDownloadBackends; + /// 다운로드 엔진 (yt-dlp, aria2). 설정값(형식 · 화질)을 읽는 함수를 받는다. + final List Function(AppSettings Function() settings)? createDownloadBackends; /// 트레이 · 전역 단축키 · 창 닫기 final AppShell shell; + /// 새 버전 확인 · 설치 (없으면 업데이트 기능 숨김) + final Updater? updater; + /// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다) final List Function(AppSettings Function() settings)? createSubtitleProviders; @@ -58,6 +64,7 @@ class PlatformServices { this.createTranslator, this.createDownloadBackends, this.createSubtitleProviders, + this.updater, ModelStore? models, AppShell? shell, }) : models = models ?? ModelStore(), @@ -75,11 +82,16 @@ class PlatformServices { createMediaPlayer: MediaKitFullPlayer.new, createRecognizer: WhisperRecognizer.new, createTranslator: NllbTranslator.new, - createDownloadBackends: () => [ - YtDlpBackend(ffmpegDir: ffmpegDir), + createDownloadBackends: (s) => [ + YtDlpBackend( + ffmpegDir: ffmpegDir, + formatArgs: () => ytDlpFormatArgs(s().ytContainer, s().ytQuality), + cookieArgs: () => ytDlpCookieArgs(browser: s().ytCookiesBrowser, file: s().ytCookiesFile), + ), Aria2Backend(), ], shell: DesktopShell(), + updater: GitHubUpdater(), createSubtitleProviders: (s) => [ OpenSubtitlesProvider( apiKey: () => s().openSubtitlesKey, diff --git a/lib/services/updater.dart b/lib/services/updater.dart new file mode 100644 index 0000000..e93321a --- /dev/null +++ b/lib/services/updater.dart @@ -0,0 +1,31 @@ +import '../core/app_update.dart'; + +class UpdateException implements Exception { + final String message; + const UpdateException(this.message); + @override + String toString() => message; +} + +/// 새 버전 확인 · 설치 경계 +/// Windows: platform/windows/github_updater.dart (zip 받아 파일 교체 후 다시 시작) +/// Android: (이식 시) 스토어 / APK 안내 +abstract class Updater { + /// 지금 실행 중인 버전 (예: 1.0.1) + Future currentVersion(); + + /// GitHub 최신 Release (없거나 읽을 수 없으면 null) + Future latest(); + + /// 프로그램 폴더에 쓸 수 있어 자동 설치가 가능한지 (아니면 페이지 안내) + Future canInstall(); + + /// zip 을 받아 SHA256 확인 후 압축을 푼 폴더 경로 + Future download(ReleaseInfo r, void Function(double progress) onProgress); + + /// 앱이 끝나면 파일을 교체하고 새 버전을 실행하도록 예약 (호출 뒤 앱을 종료해야 함) + Future scheduleInstall(String extractedDir); + + /// 브라우저로 Release 페이지 열기 + Future openPage(ReleaseInfo r); +} diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 490412f..58d0cc6 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -161,7 +161,6 @@ class _DownloadRow extends StatelessWidget { DownloadState.failed => ('실패', JjColors.danger), DownloadState.cancelled => ('취소됨', JjColors.textDim), }; - final pct = t.progress == null ? '' : '${(t.progress! * 100).toStringAsFixed(1)}%'; return InkWell( onTap: () => d.toggle(t), child: Container( @@ -180,13 +179,11 @@ class _DownloadRow extends StatelessWidget { Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13)), const SizedBox(height: 4), - if (t.state == DownloadState.downloading || t.state == DownloadState.paused) - LinearProgressIndicator(value: t.progress, minHeight: 3), - const SizedBox(height: 3), + if (t.state != DownloadState.cancelled) DownloadProgressBar(t: t), + const SizedBox(height: 4), Text( [ label, - if (pct.isNotEmpty) pct, if (t.speed.isNotEmpty) t.speed, if (t.eta.isNotEmpty) '남은 시간 ${t.eta}', if (t.error != null) t.error!.split('\n').first, @@ -210,6 +207,56 @@ class _DownloadRow extends StatelessWidget { } } +/// 퍼센트가 보이는 진행 막대 +class DownloadProgressBar extends StatelessWidget { + final DownloadTask t; + final double height; + const DownloadProgressBar({super.key, required this.t, this.height = 18}); + + @override + Widget build(BuildContext context) { + final done = t.state == DownloadState.done; + final value = done ? 1.0 : t.progress; + final color = switch (t.state) { + DownloadState.done => JjColors.success, + DownloadState.failed => JjColors.danger, + DownloadState.paused => Colors.amber, + DownloadState.cancelled => JjColors.textDim, + _ => JjColors.accent, + }; + final text = value == null + ? (t.state == DownloadState.queued ? '대기' : '준비 중…') + : '${(value * 100).toStringAsFixed(1)}%'; + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + height: height, + child: Stack(fit: StackFit.expand, children: [ + LinearProgressIndicator( + // 진행률을 모를 때(받기 준비 · 마그넷 정보 받는 중)는 움직이는 막대 + value: value ?? (t.state == DownloadState.downloading ? null : 0), + minHeight: height, + backgroundColor: JjColors.panelHigh, + color: color.withValues(alpha: 0.85), + ), + Center( + child: Text( + text, + style: TextStyle( + fontSize: height * 0.62, + height: 1, + fontWeight: FontWeight.w600, + color: Colors.white, + shadows: const [Shadow(blurRadius: 2, color: Colors.black)], + ), + ), + ), + ]), + ), + ); + } +} + /// 종료 확인. 받는 중인 다운로드가 있으면 목록을 보여 주고 한 번 더 확인한다. Future confirmExit(BuildContext context, DownloadManager d) async { if (d.activeCount == 0) return true; @@ -269,9 +316,10 @@ class _ExitDialog extends StatelessWidget { ? Icons.smart_display_outlined : Icons.cloud_download_outlined), title: Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: t.progress == null - ? null - : LinearProgressIndicator(value: t.progress, minHeight: 3), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: DownloadProgressBar(t: t, height: 14), + ), trailing: IconButton( tooltip: '삭제 (중지하고 받던 파일 삭제)', icon: const Icon(Icons.delete_outline), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 11ae347..020b9c3 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -71,7 +71,19 @@ class HomePage extends StatelessWidget { ), ), const Divider(height: 1), - SizedBox(height: 140, child: _LogPanel(logs: c.logs)), + if (c.settings.showLog) + SizedBox( + height: 140, + child: _LogPanel( + logs: c.logs, + onClose: () => c.updateSettings((s) => s.showLog = false), + ), + ) + else + _LogStrip( + last: c.logs.isEmpty ? '' : c.logs.last, + onOpen: () => c.updateSettings((s) => s.showLog = true), + ), ], ), ), @@ -185,12 +197,19 @@ class _DownloadBox extends StatelessWidget { Icon(Icons.downloading, size: 16, color: n > 0 ? JjColors.accent : JjColors.textDim), const SizedBox(width: 6), - Text(n > 0 ? '다운로딩 $n' : '다운로드 ${d.tasks.length}', + Text( + n > 0 + ? '다운로딩 $n${p == null ? '' : ' · ${(p * 100).toStringAsFixed(0)}%'}' + : '다운로드 ${d.tasks.length}', style: const TextStyle(fontSize: 12)), ]), if (n > 0) ...[ const SizedBox(height: 4), - LinearProgressIndicator(value: p, minHeight: 2), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: p, minHeight: 4, backgroundColor: JjColors.panelHigh), + ), ], ], ), @@ -660,19 +679,66 @@ class _CharsetDropdown extends StatelessWidget { class _LogPanel extends StatelessWidget { final List logs; - const _LogPanel({required this.logs}); + final VoidCallback onClose; + const _LogPanel({required this.logs, required this.onClose}); @override Widget build(BuildContext context) => Container( color: JjColors.bg, - child: ListView.builder( - reverse: true, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: logs.length, - itemBuilder: (_, i) => SelectableText( - logs[logs.length - 1 - i], - style: const TextStyle( - fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), + child: Stack(children: [ + Positioned.fill( + child: ListView.builder( + reverse: true, + padding: const EdgeInsets.fromLTRB(16, 8, 44, 8), + itemCount: logs.length, + itemBuilder: (_, i) => SelectableText( + logs[logs.length - 1 - i], + style: const TextStyle( + fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), + ), + ), + ), + // 오른쪽 위 ✕: 작업 기록 숨기기 + Positioned( + top: 2, + right: 4, + child: IconButton( + tooltip: '작업 기록 숨기기', + iconSize: 16, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: JjColors.textDim), + onPressed: onClose, + ), + ), + ]), + ); +} + +/// 작업 기록을 숨겼을 때 맨 아래 얇은 줄: 마지막 기록 + 다시 보기 +class _LogStrip extends StatelessWidget { + final String last; + final VoidCallback onOpen; + const _LogStrip({required this.last, required this.onOpen}); + + @override + Widget build(BuildContext context) => Material( + color: JjColors.bg, + child: InkWell( + onTap: onOpen, + child: SizedBox( + height: 26, + child: Row(children: [ + const SizedBox(width: 16), + Expanded( + child: Text(last, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)), + ), + const Text('작업 기록 보기', style: TextStyle(fontSize: 11, color: JjColors.accent)), + const Icon(Icons.expand_less, size: 16, color: JjColors.accent), + const SizedBox(width: 12), + ]), ), ), ); diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index b6f0e53..721a21e 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -63,7 +63,14 @@ class _PlayerPageState extends State { super.initState(); pl.open(widget.files, start: widget.start); pl.state.addListener(_onState); - _errSub = pl.errors.listen((e) => setState(() => _error = e)); + // 오류는 3초 뒤에도 실제로 재생이 안 될 때만 표시 (mpv 는 대체 방법으로 계속 재생하는 경우가 많음) + _errSub = pl.errors.listen((e) { + Future.delayed(const Duration(seconds: 3), () { + final s = pl.state.value; + final stuck = s.position < const Duration(milliseconds: 500) && s.duration == Duration.zero; + if (mounted && stuck) setState(() => _error = e); + }); + }); } @override diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 0fa89fe..cc8f256 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -3,13 +3,87 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import '../app/app_controller.dart'; +import '../core/download_detect.dart'; import '../core/playlist.dart'; import '../core/subtitle_detector.dart'; import '../services/app_shell.dart'; import 'setup_dialog.dart'; +import 'update_dialog.dart'; import 'theme.dart'; +/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 +class CountSelector extends StatelessWidget { + final int value; + final ValueChanged onChanged; + const CountSelector({super.key, required this.value, required this.onChanged}); + + static const _presets = [1, 5, 10, 0]; + static String label(int n) => n <= 0 ? '무한' : '$n개'; + + Future _custom(BuildContext context) async { + // 목록 메뉴가 완전히 닫힌 뒤 입력 창을 연다 + await Future.delayed(Duration.zero); + if (!context.mounted) return; + final n = await showDialog( + context: context, + builder: (_) => _CountInputDialog(initial: value), + ); + if (n != null && n >= 0) onChanged(n); + } + + @override + Widget build(BuildContext context) { + final items = {..._presets, value}; + return DropdownButton( + value: value, + items: [ + for (final n in items) DropdownMenuItem(value: n, child: Text(label(n))), + const DropdownMenuItem(value: -1, child: Text('직접 입력…')), + ], + onChanged: (n) => n == -1 ? _custom(context) : onChanged(n!), + ); + } +} + +/// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리) +class _CountInputDialog extends StatefulWidget { + final int initial; + const _CountInputDialog({required this.initial}); + + @override + State<_CountInputDialog> createState() => _CountInputDialogState(); +} + +class _CountInputDialogState extends State<_CountInputDialog> { + late final _ctrl = TextEditingController(text: widget.initial > 0 ? '${widget.initial}' : ''); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('직접 입력'), + content: TextField( + controller: _ctrl, + autofocus: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: '동시에 처리할 개수 (0 = 무한)', border: OutlineInputBorder()), + onSubmitted: (t) => Navigator.pop(context, int.tryParse(t)), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('취소')), + FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(_ctrl.text)), child: const Text('확인')), + ], + ); +} + /// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정) class _ExternalPlayers extends StatelessWidget { final AppController c; @@ -234,6 +308,84 @@ class _SettingsPageState extends State { (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), _textTile('비밀번호 (선택)', s.openSubtitlesPassword, '이 PC 의 설정 파일에 저장됩니다', (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), + ListTile( + title: const Text('YouTube 받을 형식'), + subtitle: const Text('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장'), + trailing: DropdownButton( + value: s.ytContainer, + items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), + ), + ), + ListTile( + enabled: !s.ytContainer.audioOnly, + title: const Text('YouTube 화질'), + subtitle: const Text('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)'), + trailing: DropdownButton( + value: s.ytQuality, + items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!), + ), + ), + SwitchListTile( + value: s.ytExpandPlaylists, + onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v), + title: const Text('재생목록 주소면 목록 전체 받기'), + subtitle: const Text('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)'), + ), + ListTile( + title: const Text('YouTube 쿠키 (로봇 확인이 나올 때)'), + subtitle: Text(s.ytCookiesFile.isNotEmpty + ? 'cookies.txt: ${s.ytCookiesFile}' + : s.ytCookiesBrowser.isNotEmpty + ? '${s.ytCookiesBrowser} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)' + : '사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요'), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + DropdownButton( + value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser, + items: [ + const DropdownMenuItem(value: '', child: Text('사용 안 함')), + for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera']) + DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))), + const DropdownMenuItem(value: '*file', child: Text('cookies.txt 파일…')), + ], + onChanged: (v) async { + if (v == '*file') { + final f = await FilePicker.pickFile( + dialogTitle: 'cookies.txt (Netscape 형식)', + type: FileType.custom, + allowedExtensions: const ['txt']); + if (f?.path != null) { + await c.updateSettings((x) => x + ..ytCookiesFile = f!.path! + ..ytCookiesBrowser = ''); + } + } else { + await c.updateSettings((x) => x + ..ytCookiesBrowser = v ?? '' + ..ytCookiesFile = ''); + } + }, + ), + ]), + ), + ListTile( + title: const Text('동시 다운로드 수'), + subtitle: const Text('나머지는 대기했다가 차례로 받습니다'), + trailing: CountSelector( + value: s.maxParallelDownloads, + onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n), + ), + ), + _section('변환'), + ListTile( + title: const Text('동시 MKV 변환 수'), + subtitle: const Text('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)'), + trailing: CountSelector( + value: s.maxParallelJobs, + onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), + ), + ), _section('실행'), SwitchListTile( value: s.minimizeToTray, @@ -270,6 +422,24 @@ class _SettingsPageState extends State { '언어 ${c.aiOptions.targets.map((t) => t.code).join('/')}, ${c.aiOptions.whisper.label}'), ), _section('프로그램'), + if (c.services.updater != null) ...[ + ListTile( + title: const Text('업데이트'), + subtitle: FutureBuilder( + future: c.services.updater!.currentVersion(), + builder: (_, v) => Text('지금 버전: v${v.data ?? '…'} · GitHub 최신 Release 와 비교합니다'), + ), + trailing: OutlinedButton( + onPressed: () => checkForUpdate(context, c, manual: true), + child: const Text('지금 확인'), + ), + ), + SwitchListTile( + value: s.autoCheckUpdates, + onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v), + title: const Text('시작할 때 새 버전 확인 (하루 한 번)'), + ), + ], ListTile( title: const Text('필수 프로그램 점검'), subtitle: const Text('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다'), diff --git a/lib/ui/update_dialog.dart b/lib/ui/update_dialog.dart new file mode 100644 index 0000000..cc576f7 --- /dev/null +++ b/lib/ui/update_dialog.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../core/app_update.dart'; +import 'theme.dart'; + +/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 +/// +/// [manual]: 환경 설정에서 "지금 확인" 을 누른 경우 (최신이어도 알려 주고, 건너뛴 버전도 보여 줌) +Future checkForUpdate(BuildContext context, AppController c, {bool manual = false}) async { + final up = c.services.updater; + if (up == null) return; + final s = c.settings; + if (!manual && (!s.autoCheckUpdates || !updateCheckDue(s.lastUpdateCheck, DateTime.now()))) return; + + void snack(String t) { + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); + } + + final String current; + final ReleaseInfo? r; + try { + current = await up.currentVersion(); + r = await up.latest(); + } catch (e) { + if (manual) snack('새 버전을 확인할 수 없습니다: $e'); + return; + } + await c.updateSettings((x) => x.lastUpdateCheck = DateTime.now().toIso8601String()); + if (r == null || !r.isNewerThan(current)) { + if (manual) snack('최신 버전입니다 (v$current)'); + return; + } + if (!manual && s.skippedVersion == r.version) return; + if (!context.mounted) return; + + final choice = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('새 버전 v${r!.version}'), + content: SizedBox( + width: 560, + height: 380, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('지금 쓰는 버전: v$current → 새 버전: v${r.version}'), + if (r.zipSize > 0) + Text('받는 크기: 약 ${(r.zipSize / 1e6).round()}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다', + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 12), + const Text('바뀐 내용', style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)), + child: SingleChildScrollView( + child: SelectableText(r.notes.isEmpty ? '(설명 없음)' : r.notes, + style: const TextStyle(fontSize: 12, height: 1.5)), + ), + ), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: const Text('이 버전 건너뛰기')), + TextButton(onPressed: () => Navigator.pop(ctx, 'page'), child: const Text('페이지 열기')), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('나중에')), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, 'update'), + icon: const Icon(Icons.system_update_alt, size: 18), + label: const Text('업데이트'), + ), + ], + ), + ); + switch (choice) { + case 'skip': + await c.updateSettings((x) => x.skippedVersion = r!.version); + return; + case 'page': + await up.openPage(r); + return; + case 'update': + break; + default: + return; + } + + if (c.busy) { + snack('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.'); + return; + } + // 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록 + if (r.zipUrl == null || !await up.canInstall()) { + snack('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.'); + await up.openPage(r); + return; + } + if (!context.mounted) return; + + final progress = ValueNotifier(0); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text('v${r!.version} 받는 중'), + content: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + Text(v >= 1 ? '확인 · 압축 푸는 중…' : '${(v * 100).round()}%'), + const SizedBox(height: 12), + LinearProgressIndicator(value: v >= 1 ? null : v), + ]), + ), + ), + ); + String dir; + try { + dir = await up.download(r, (x) => progress.value = x); + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + snack('업데이트 실패: $e'); + return; + } + if (!context.mounted) return; + Navigator.of(context).pop(); + + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('설치 준비 완료'), + content: Text('v${r!.version} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' + '프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('나중에')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('지금 설치')), + ], + ), + ); + if (go != true) return; + // 다운로드 중이면 기존 종료 확인 절차 + final ok = await (c.confirmQuit?.call() ?? Future.value(true)); + if (!ok) return; + await up.scheduleInstall(dir); + await c.services.shell.quit(); +} diff --git a/pubspec.lock b/pubspec.lock index 4cf2544..8673528 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -106,7 +106,7 @@ packages: source: hosted version: "0.3.5+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -544,7 +544,7 @@ packages: source: hosted version: "3.0.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" diff --git a/pubspec.yaml b/pubspec.yaml index 64c94f8..999dbed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.1+2 environment: sdk: ^3.13.4 @@ -52,6 +52,8 @@ dependencies: hotkey_manager: ^0.2.3 desktop_drop: ^0.8.0 screen_retriever: ^0.2.2 + package_info_plus: ^10.2.1 + crypto: ^3.0.7 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/download_test.dart b/test/download_test.dart index a61b6f4..cc0f873 100644 --- a/test/download_test.dart +++ b/test/download_test.dart @@ -18,6 +18,12 @@ class _FakeBackend implements DownloadBackend { final started = []; _FakeBackend(this.kind); + /// 재생목록 흉내 (null 이면 재생목록 아님) + (String, List)? playlist; + + @override + Future<(String, List)?> expandPlaylist(String url) async => playlist; + @override Future start(DownloadTask t, void Function() changed) async { started.add(t.source); diff --git a/test/downloads_ui_test.dart b/test/downloads_ui_test.dart index 003de56..b1e83a0 100644 --- a/test/downloads_ui_test.dart +++ b/test/downloads_ui_test.dart @@ -11,6 +11,8 @@ class _Backend implements DownloadBackend { final DownloadKind kind; _Backend(this.kind); @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override Future start(DownloadTask t, void Function() changed) async { t ..state = DownloadState.downloading @@ -51,7 +53,10 @@ void main() { await tester.pump(); expect(find.text('테스트 영상'), findsOneWidget); expect(find.text('리눅스 ISO'), findsOneWidget); - expect(find.textContaining('받는 중 · 40.0% · 1.2MB/s'), findsNWidgets(2)); + // 진행 막대 안에 퍼센트, 아래에 상태 · 속도 + expect(find.byType(DownloadProgressBar), findsNWidgets(2)); + expect(find.text('40.0%'), findsNWidgets(2)); + expect(find.textContaining('받는 중 · 1.2MB/s'), findsNWidgets(2)); await tester.tap(find.text('전체 선택')); await tester.pump(); @@ -61,8 +66,12 @@ void main() { expect(d.tasks.every((t) => t.state == DownloadState.paused), isTrue); d.tasks.first.state = DownloadState.done; + d.tasks.last.progress = null; // 진행률을 모르는 상태 (마그넷 정보 받는 중 등) + d.tasks.last.state = DownloadState.downloading; d.selectNone(); // 화면 갱신 await tester.pump(); + expect(find.text('100.0%'), findsOneWidget); // 완료는 가득 찬 막대 + expect(find.text('준비 중…'), findsOneWidget); await tester.tap(find.text('완료 정리')); await tester.pump(); expect(d.tasks, hasLength(1)); diff --git a/test/queue_test.dart b/test/queue_test.dart new file mode 100644 index 0000000..cd8327f --- /dev/null +++ b/test/queue_test.dart @@ -0,0 +1,242 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/main.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/services/media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:path/path.dart' as p; + +/// 시작만 하고 끝내기는 테스트가 직접 (finish) +class _Backend implements DownloadBackend { + @override + final DownloadKind kind; + _Backend(this.kind); + final running = {}; + (String, List)? playlist; + + @override + Future<(String, List)?> expandPlaylist(String url) async => playlist; + @override + Future start(DownloadTask t, void Function() changed) async { + t.state = DownloadState.downloading; + running[t] = changed; + changed(); + } + + void finish(DownloadTask t) { + t.state = DownloadState.done; + running.remove(t)!(); + } + + @override + Future pause(DownloadTask t) async { + running.remove(t); + t.state = DownloadState.paused; + } + + @override + Future cancel(DownloadTask t) async { + running.remove(t); + t.state = DownloadState.cancelled; + } + + @override + Future shutdown() async {} +} + +/// 동시에 몇 개가 실행되는지 재는 가짜 FFmpeg +class _Tool implements MediaTool { + var now = 0, peak = 0; + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {'libx264'}; + @override + Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress}) async { + now++; + if (now > peak) peak = now; + await Future.delayed(const Duration(milliseconds: 30)); + now--; + } + + @override + void cancel() {} +} + +void main() { + group('YouTube 주소 · 형식', () { + test('재생목록 판별', () { + expect(youtubePlaylistId('https://www.youtube.com/playlist?list=PLabc_123'), 'PLabc_123'); + expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1&list=PLxyz&index=3'), 'PLxyz'); + expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1&list=RDx1'), isNull); // 믹스 + expect(youtubePlaylistId('https://www.youtube.com/watch?v=x1'), isNull); + }); + test('폴더 이름 정리', () { + expect(safeFolderName('My: List / 2024?'), 'My List 2024'); + expect(safeFolderName('끝 점...'), '끝 점'); + }); + test('형식 인수', () { + expect(ytDlpFormatArgs(YtContainer.mp4, YtQuality.p1080).join(' '), + '-S res:1080,ext:mp4:m4a --merge-output-format mp4 --remux-video mp4'); + expect(ytDlpFormatArgs(YtContainer.webm, YtQuality.best).join(' '), '-S ext:webm:webm --merge-output-format webm'); + expect(ytDlpFormatArgs(YtContainer.mp3, YtQuality.p720), contains('mp3')); + expect(ytDlpCookieArgs(browser: 'firefox'), ['--cookies-from-browser', 'firefox']); + expect(ytDlpCookieArgs(browser: 'firefox', file: r'C:\c.txt'), ['--cookies', r'C:\c.txt']); + expect(ytDlpCookieArgs(), isEmpty); + expect(friendlyYtDlpError('ERROR: [youtube] x: Sign in to confirm you’re not a bot'), contains('로봇 확인')); + }); + }); + + group('다운로드 대기열 · 재생목록', () { + late _Backend video; + late AppSettings settings; + late DownloadManager d; + setUp(() { + video = _Backend(DownloadKind.video); + settings = AppSettings() + ..downloadRoot = r'D:\dl' + ..maxParallelDownloads = 2; + d = DownloadManager(backends: [video], settings: () => settings, readClipboard: () async => null); + }); + tearDown(() => d.dispose()); + + test('동시 2개, 끝나면 다음 차례 (들어온 순서)', () async { + final ts = [for (var i = 1; i <= 5; i++) d.add('https://youtu.be/v$i')!]; + await Future.delayed(Duration.zero); + expect(ts.map((t) => t.state.name), ['downloading', 'downloading', 'queued', 'queued', 'queued']); + video.finish(ts[0]); + await Future.delayed(Duration.zero); + expect(ts[2].state, DownloadState.downloading); + expect(ts[3].state, DownloadState.queued); + // 일시정지하면 자리가 비어 다음이 시작, 재개하면 대기열로 + await d.pause([ts[1]]); + expect(ts[3].state, DownloadState.downloading); + await d.resume([ts[1]]); + expect(ts[1].state, DownloadState.queued); + settings.maxParallelDownloads = 0; // 무한 + await d.resume(const []); + expect(ts.where((t) => t.state == DownloadState.downloading), hasLength(4)); + }); + + test('재생목록 → 영상별 항목, 하위 폴더, 이미 받는 영상 제외', () async { + d.add('https://youtu.be/b'); + video.playlist = ('My: Playlist', const [ + PlaylistEntry('https://youtu.be/a', '첫째'), + PlaylistEntry('https://youtu.be/b', '둘째'), + PlaylistEntry('https://youtu.be/c', '셋째'), + ]); + final holder = d.add('https://www.youtube.com/playlist?list=PLtest')!; + expect(holder.expanding, isTrue); + expect(holder.title, contains('재생목록 불러오는 중')); + await Future.delayed(const Duration(milliseconds: 10)); + final titles = d.tasks.map((t) => t.title).toList(); + expect(titles.take(2), ['[1/3] 첫째', '[3/3] 셋째']); + expect(d.tasks.first.dir, p.join(r'D:\dl', 'jj_yt-dlp', 'My Playlist')); + expect(d.tasks.contains(holder), isFalse); + + settings.ytExpandPlaylists = false; + final single = d.add('https://www.youtube.com/watch?v=z&list=PLother')!; + expect(single.expanding, isFalse); + }); + }); + + test('MKV 동시 변환 수: 1 · 2 · 무한', () async { + for (final (limit, expected) in [(1, 1), (2, 2), (0, 5)]) { + final tool = _Tool(); + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + await c.init(); + c.settings.maxParallelJobs = limit; + final dir = Directory.systemTemp.createTempSync('jj_par_'); + for (var i = 0; i < 5; i++) { + c.videos.add(VideoItem(p.join(dir.path, 'v$i.mp4'))..info = const MediaInfo(duration: Duration(seconds: 1))); + } + await c.buildAll(); + dir.deleteSync(recursive: true); + expect(tool.peak, expected, reason: '최대 $limit'); + expect(c.videos.every((v) => v.status == JobStatus.done), isTrue); + } + }); + + test('실제 yt-dlp 재생목록 읽기 (JJ_NET_TESTS=1)', () async { + final exe = p.join(Directory.current.path, 'third_party', 'tools', 'windows', 'yt-dlp.exe'); + if (Platform.environment['JJ_NET_TESTS'] != '1' || !File(exe).existsSync()) { + return markTestSkipped('인터넷 테스트 꺼짐'); + } + final b = YtDlpBackend(ytdlp: exe, deno: p.join(p.dirname(exe), 'deno.exe')); + final r = await b.expandPlaylist('https://www.youtube.com/watch?v=x&list=PLzH6n4zXuckpfMu_4Ff8E7Z1behQks5ba'); + expect(r, isNotNull); + expect(r!.$1, contains('Data Analysis')); + expect(r.$2.length, greaterThanOrEqualTo(10)); + expect(r.$2.first.url, startsWith('https://www.youtube.com/watch?v=')); + }, timeout: const Timeout(Duration(minutes: 2))); + + testWidgets('작업 기록 ✕ → 숨김, 아래 줄 누르면 다시 보임', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService())); + await c.init(); + await tester.pumpWidget(JjCapCutApp(controller: c)); + expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget); + await tester.tap(find.byTooltip('작업 기록 숨기기')); + await tester.pump(); + expect(c.settings.showLog, isFalse); + expect(find.text('작업 기록 보기'), findsOneWidget); + await tester.tap(find.text('작업 기록 보기')); + await tester.pump(); + expect(c.settings.showLog, isTrue); + expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget); + }); + + testWidgets('환경 설정: 새 항목이 깨지지 않고 보임', (tester) async { + // 스크롤 없이 모두 보이도록 세로로 긴 화면 + tester.view.physicalSize = const Size(1400, 3200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService())); + await tester.pumpWidget(MaterialApp(home: SettingsPage(c: c))); + await tester.pumpAndSettle(); + for (final t in ['YouTube 받을 형식', 'YouTube 화질', '재생목록 주소면 목록 전체 받기', 'YouTube 쿠키 (로봇 확인이 나올 때)', + '동시 다운로드 수', '동시 MKV 변환 수']) { + expect(find.text(t), findsOneWidget, reason: t); + } + expect(find.text('MP4 (동영상)'), findsOneWidget); + expect(find.text('3개'), findsOneWidget); // 동시 다운로드 기본 + expect(find.text('5개'), findsOneWidget); // 동시 변환 기본 + }); + + testWidgets('개수 선택: 1 · 5 · 10 · 무한 · 직접 입력', (tester) async { + var value = 5; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (_, set) => CountSelector(value: value, onChanged: (n) => set(() => value = n)), + ), + ), + )); + await tester.tap(find.text('5개')); + await tester.pumpAndSettle(); + for (final t in ['1개', '10개', '무한', '직접 입력…']) { + expect(find.text(t), findsWidgets); + } + await tester.tap(find.text('직접 입력…').last); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '7'); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + expect(value, 7); + expect(find.text('7개'), findsOneWidget); + }); +} diff --git a/test/update_test.dart b/test/update_test.dart new file mode 100644 index 0000000..5910584 --- /dev/null +++ b/test/update_test.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/app_update.dart'; +import 'package:jj_mkvmaker/platform/windows/github_updater.dart'; +import 'package:path/path.dart' as p; + +// 화면 테스트는 update_ui_test.dart (화면 테스트가 같은 파일에 있으면 실제 네트워크 연결이 막힘) + +Map _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => { + 'tag_name': tag, + 'name': 'JJ_MKVMaker $tag', + 'body': body, + 'draft': draft, + 'prerelease': false, + 'html_url': 'https://github.com/$updateRepo/releases/tag/$tag', + 'assets': [ + { + 'name': 'JJ_MKVMaker_${tag}_win64.zip', + 'size': 1234, + 'browser_download_url': url ?? 'https://example.com/x.zip', + 'digest': ?digest, + }, + ], + }; + +void main() { + group('버전 · Release 해석', () { + test('버전 비교', () { + expect(compareVersions('1.0.1', '1.0.0'), greaterThan(0)); + expect(compareVersions('v1.2', '1.2.0'), 0); + expect(compareVersions('1.10.0', '1.9.9'), greaterThan(0)); + expect(compareVersions('1.0.0+5', 'v1.0.1'), lessThan(0)); + }); + test('releases/latest 해석', () { + final r = parseLatestRelease(_release('v1.2.0', digest: 'sha256:${'A' * 64}'))!; + expect([r.version, r.tag, r.zipName, r.sha256], ['1.2.0', 'v1.2.0', 'JJ_MKVMaker_v1.2.0_win64.zip', 'a' * 64]); + expect(r.isNewerThan('1.0.1'), isTrue); + // digest 없으면 설명의 SHA256 + final b = parseLatestRelease(_release('v1.2.0', body: '| zip | `${'B' * 64}` |'))!; + expect(b.sha256, 'b' * 64); + expect(parseLatestRelease(_release('v9.0.0', draft: true)), isNull); + }); + test('하루 한 번', () { + final now = DateTime(2026, 10, 1, 12); + expect(updateCheckDue('', now), isTrue); + expect(updateCheckDue(DateTime(2026, 10, 1, 9).toIso8601String(), now), isFalse); + expect(updateCheckDue(DateTime(2026, 9, 30, 9).toIso8601String(), now), isTrue); + }); + }); + + group('GitHubUpdater (가짜 GitHub 서버 · 실제 스크립트)', () { + late HttpServer server; + late Directory tmp; + late List zipBytes; + var digest = ''; + + setUpAll(() async { + tmp = Directory.systemTemp.createTempSync('jj_upd_'); + // 새 버전 zip: JJ_MKVMaker\jj_mkvmaker.exe · new.txt · data\app.so + final src = Directory(p.join(tmp.path, 'pkg', 'JJ_MKVMaker'))..createSync(recursive: true); + File(p.join(src.path, 'jj_mkvmaker.exe')).writeAsStringSync('NEW EXE'); + File(p.join(src.path, 'new.txt')).writeAsStringSync('new file'); + Directory(p.join(src.path, 'data')).createSync(); + File(p.join(src.path, 'data', 'app.so')).writeAsStringSync('NEW DATA'); + final zip = p.join(tmp.path, 'u.zip'); + final r = await Process.run('tar', ['-a', '-cf', zip, '-C', p.join(tmp.path, 'pkg'), 'JJ_MKVMaker']); + expect(r.exitCode, 0, reason: '${r.stderr}'); + zipBytes = File(zip).readAsBytesSync(); + digest = sha256.convert(zipBytes).toString(); + + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + final base = 'http://127.0.0.1:${server.port}'; + if (req.uri.path == '/repos/$updateRepo/releases/latest') { + req.response.headers.contentType = ContentType.json; + final bad = req.uri.queryParameters['bad'] == '1'; + req.response.write(jsonEncode(_release('v1.9.0', + digest: 'sha256:${bad ? '0' * 64 : digest}', url: '$base/dl/u.zip'))); + } else if (req.uri.path == '/dl/u.zip') { + req.response + ..contentLength = zipBytes.length + ..add(zipBytes); + } else { + req.response.statusCode = 404; + } + await req.response.close(); + }); + }); + tearDownAll(() async { + await server.close(force: true); + tmp.deleteSync(recursive: true); + }); + + test('최신 확인 → 받기 · SHA256 확인 · 압축 풀기', () async { + final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path, versionOverride: '1.0.1'); + final r = (await up.latest())!; + expect(r.isNewerThan(await up.currentVersion()), isTrue); + final progress = []; + final dir = await up.download(r, progress.add); + expect(File(p.join(dir, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE'); + expect(progress.last, 1); + }); + + test('SHA256 이 다르면 설치 거부', () async { + final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: tmp.path); + final good = (await up.latest())!; + final bad = ReleaseInfo( + version: good.version, tag: good.tag, name: '', notes: '', pageUrl: '', + zipUrl: good.zipUrl, zipName: good.zipName, sha256: '0' * 64); + await expectLater(up.download(bad, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256')))); + }); + + test('업데이트 스크립트: 프로그램 파일만 교체, 받은 파일 · 모델 보존', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용'); + final app = Directory(p.join(tmp.path, 'installed'))..createSync(); + File(p.join(app.path, 'jj_mkvmaker.exe')).writeAsStringSync('OLD EXE'); + File(p.join(app.path, 'old_only.txt')).writeAsStringSync('keep me'); + Directory(p.join(app.path, 'jj_yt-dlp')).createSync(); + File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).writeAsStringSync('my download'); + Directory(p.join(app.path, 'models')).createSync(); + File(p.join(app.path, 'models', 'm.bin')).writeAsStringSync('model'); + + final up = GitHubUpdater(apiBase: 'http://127.0.0.1:${server.port}', appDir: app.path); + final dir = await up.download((await up.latest())!, (_) {}); + // 이미 끝난 프로세스를 "앱" 으로 두고 실행 (바로 교체 진행) + final dummy = await Process.start('cmd', ['/c', 'exit']); + await dummy.exitCode; + final proc = await up.runInstallScript(dir, waitPid: dummy.pid, restart: false); + expect(await proc.exitCode, 0); + + expect(File(p.join(app.path, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE'); + expect(File(p.join(app.path, 'new.txt')).existsSync(), isTrue); + expect(File(p.join(app.path, 'data', 'app.so')).readAsStringSync(), 'NEW DATA'); + expect(File(p.join(app.path, 'old_only.txt')).readAsStringSync(), 'keep me'); + expect(File(p.join(app.path, 'jj_yt-dlp', 'video.mp4')).readAsStringSync(), 'my download'); + expect(File(p.join(app.path, 'models', 'm.bin')).readAsStringSync(), 'model'); + }, timeout: const Timeout(Duration(minutes: 2))); + }); +} diff --git a/test/update_ui_test.dart b/test/update_ui_test.dart new file mode 100644 index 0000000..a53a38e --- /dev/null +++ b/test/update_ui_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/app_update.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/services/updater.dart'; +import 'package:jj_mkvmaker/ui/update_dialog.dart'; + +Map _release(String tag, {String? digest, String body = '', bool draft = false, String? url}) => { + 'tag_name': tag, + 'name': 'JJ_MKVMaker $tag', + 'body': body, + 'draft': draft, + 'prerelease': false, + 'html_url': 'https://github.com/$updateRepo/releases/tag/$tag', + 'assets': [ + { + 'name': 'JJ_MKVMaker_${tag}_win64.zip', + 'size': 1234, + 'browser_download_url': url ?? 'https://example.com/x.zip', + 'digest': ?digest, + }, + ], + }; + +class _FakeUpdater implements Updater { + ReleaseInfo? next; + final calls = []; + @override + Future currentVersion() async => '1.0.1'; + @override + Future latest() async => next; + @override + Future canInstall() async => true; + @override + Future download(ReleaseInfo r, void Function(double) onProgress) async { + calls.add('download ${r.version}'); + onProgress(1); + return r'C:\tmp\new'; + } + + @override + Future scheduleInstall(String dir) async => calls.add('install $dir'); + @override + Future openPage(ReleaseInfo r) async => calls.add('page'); +} + +void main() { + group('업데이트 창', () { + late _FakeUpdater up; + late AppController c; + setUp(() { + up = _FakeUpdater(); + c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up)); + }); + + Future open(WidgetTester tester, {bool manual = true}) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => TextButton( + onPressed: () => checkForUpdate(ctx, c, manual: manual), + child: const Text('확인'), + ), + ), + ), + )); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + } + + testWidgets('최신이면 알림만', (tester) async { + up.next = parseLatestRelease(_release('v1.0.1')); + await open(tester); + expect(find.text('최신 버전입니다 (v1.0.1)'), findsOneWidget); + }); + + testWidgets('새 버전 → 업데이트 → 받기 → 지금 설치', (tester) async { + up.next = parseLatestRelease(_release('v1.1.0', body: '- 재생목록 전체 받기')); + await open(tester); + expect(find.text('새 버전 v1.1.0'), findsOneWidget); + expect(find.text('지금 쓰는 버전: v1.0.1 → 새 버전: v1.1.0'), findsOneWidget); + expect(find.text('- 재생목록 전체 받기'), findsOneWidget); + await tester.tap(find.text('업데이트')); + await tester.pumpAndSettle(); + expect(find.text('설치 준비 완료'), findsOneWidget); + await tester.tap(find.text('지금 설치')); + await tester.pumpAndSettle(); + expect(up.calls, ['download 1.1.0', r'install C:\tmp\new']); + }); + + testWidgets('이 버전 건너뛰기 → 자동 확인에서 다시 묻지 않음', (tester) async { + up.next = parseLatestRelease(_release('v1.1.0')); + await open(tester); + await tester.tap(find.text('이 버전 건너뛰기')); + await tester.pumpAndSettle(); + expect(c.settings.skippedVersion, '1.1.0'); + c.settings.lastUpdateCheck = ''; + await open(tester, manual: false); + expect(find.text('새 버전 v1.1.0'), findsNothing); + }); + }); +}