From 5de4412dc284c027d3a4321d75df94f0e7f9f527 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:56:07 +0900 Subject: [PATCH] =?UTF-8?q?app=5Fcontroller=20=EB=82=98=EB=88=84=EA=B8=B0?= =?UTF-8?q?=20(1672=20=E2=86=92=20506):=20=EC=9D=BC=EC=9D=80=20lib/app/con?= =?UTF-8?q?troller/=20=EB=AA=A8=EB=93=88=EC=9D=B4=20=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=83=81=ED=83=9C=EB=8A=94=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작업 대기열 (JobQueue) · 작업 기록 (AppLog) · 목록 공유 (VideoListShare) · 편집 목록 (VideoList) - 동영상 분석 (VideoProbe) · 파일 옮기기 (VideoFiles) · 자막 파일 (SubtitleFiles) · 인터넷 자막 (SubtitleDownload) - AI 자막 · 자막 번역 (AiSubtitleRunner) · MKV 묶음 (MkvBatch) · 인코딩 고르기 (EncodeChoice) · 재생 준비 (PlaybackPrep) - 실행 중 설정 · 켤 때 정리 · 다운로드 드라이브 (app_runtime), AiOptions - 공개 이름은 그대로 (화면 · 시험이 부르는 것), 같은 번역 줄 만들기 두 곳은 하나로 - 약속 시험: JobQueue · AppLog · EncodeChoice · VideoListShare · VideoFiles · VideoList Co-Authored-By: Claude Opus 5.5 --- lib/app/app_controller.dart | 1522 +++----------------- lib/app/controller/ai_options.dart | 47 + lib/app/controller/ai_subtitle_runner.dart | 302 ++++ lib/app/controller/app_log.dart | 31 + lib/app/controller/app_runtime.dart | 76 + lib/app/controller/encode_choice.dart | 55 + lib/app/controller/job_queue.dart | 64 + lib/app/controller/mkv_batch.dart | 230 +++ lib/app/controller/playback_prep.dart | 49 + lib/app/controller/subtitle_download.dart | 76 + lib/app/controller/subtitle_files.dart | 169 +++ lib/app/controller/video_files.dart | 57 + lib/app/controller/video_list.dart | 225 +++ lib/app/controller/video_list_share.dart | 74 + lib/app/controller/video_probe.dart | 84 ++ test/controller_modules_contract_test.dart | 136 ++ test/restore_access_test.dart | 3 +- 17 files changed, 1855 insertions(+), 1345 deletions(-) create mode 100644 lib/app/controller/ai_options.dart create mode 100644 lib/app/controller/ai_subtitle_runner.dart create mode 100644 lib/app/controller/app_log.dart create mode 100644 lib/app/controller/app_runtime.dart create mode 100644 lib/app/controller/encode_choice.dart create mode 100644 lib/app/controller/job_queue.dart create mode 100644 lib/app/controller/mkv_batch.dart create mode 100644 lib/app/controller/playback_prep.dart create mode 100644 lib/app/controller/subtitle_download.dart create mode 100644 lib/app/controller/subtitle_files.dart create mode 100644 lib/app/controller/video_files.dart create mode 100644 lib/app/controller/video_list.dart create mode 100644 lib/app/controller/video_list_share.dart create mode 100644 lib/app/controller/video_probe.dart create mode 100644 test/controller_modules_contract_test.dart diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index fa8f0b5..9f6e164 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1,79 +1,42 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; -import '../core/charset_detector.dart'; import '../core/encode_options.dart'; import '../core/languages.dart'; -import '../core/mkv_command_builder.dart'; -import '../core/vfs.dart'; -import '../core/sftp.dart' show SftpRegistry; -import '../core/smb.dart' show SmbRegistry; -import '../core/ftp.dart' show FtpRegistry; -import '../core/webdav.dart'; +import '../core/mkv_command_builder.dart' show bitmapSubtitleCodecs; import '../core/models.dart'; -import '../core/output_paths.dart'; -import '../core/playlist.dart'; import '../core/srt.dart'; import '../core/subtitle_detector.dart'; import '../core/subtitle_search.dart'; import '../services/subtitle_provider.dart'; import '../core/text_codec.dart'; -import '../core/ai_subtitle.dart'; -import '../services/ai_services.dart'; -import '../services/media_player.dart' show playerSubtitleScale; import '../services/media_tool.dart'; -import '../services/model_store.dart'; import '../services/platform_services.dart'; import '../services/system_usage.dart'; import 'settings.dart'; import 'cleanup.dart' show removeUpdateLeftovers; -import '../core/hwp_source.dart' show removeStaleHwpWork; -import '../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews, vaultViewRootOf; import '../l10n/tr.dart'; -import '../core/remote_idle.dart'; +import 'controller/ai_options.dart'; +import 'controller/ai_subtitle_runner.dart'; +import 'controller/app_log.dart'; +import 'controller/encode_choice.dart'; +import 'controller/job_queue.dart'; +import 'controller/mkv_batch.dart'; +import 'controller/subtitle_download.dart'; +import 'controller/subtitle_files.dart'; +import 'controller/app_runtime.dart'; +import 'controller/playback_prep.dart'; +import 'controller/video_list.dart'; +import 'controller/video_list_share.dart'; +import 'controller/video_probe.dart'; -extension on Set { - Set ifEmpty(Set other) => isEmpty ? other : this; -} +export 'controller/ai_options.dart'; -/// AI 자막 설정 -class AiOptions { - /// 원어 (undetermined = 자동 감지) - final Language source; - - /// 만들 언어 (기본: 한국어·영어·일본어) - final Set targets; - final ModelSpec whisper; - - const AiOptions({ - this.source = undetermined, - required this.targets, - required this.whisper, - }); - - /// 기본값: 한국어·영어·일본어 - static AiOptions defaults() => - AiOptions(targets: {for (final c in defaultTargetLanguages) languageOf(c)}, whisper: whisperModels.first); - - /// 원어와 다른 언어가 있으면 번역 모델 필요 (원어 자동이면 필요하다고 봄) - bool get needsTranslation => - source == undetermined || targets.any((t) => t.code != source.code); - - /// 259: 시작 전에 이미 번역이 필요한 것이 확실한지 - 원어를 골랐으면 다른 언어가 있을 때, - /// 자동 감지면 만들 언어가 둘 이상일 때 (하나뿐이면 감지한 원어가 그 언어일 수 있어 감지한 뒤에 정한다) - bool get surelyNeedsTranslation => - source == undetermined ? targets.length > 1 : targets.any((t) => t.code != source.code); - - AiOptions copyWith({Language? source, Set? targets, ModelSpec? whisper}) => AiOptions( - source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper); -} - -/// 화면 상태와 작업 흐름 (플랫폼 무관) +/// 화면 상태와 작업 흐름 (플랫폼 무관). 일은 controller/ 의 모듈이 하고, 여기는 목록 · 선택 · 설정과 모듈을 잇기만 한다 class AppController extends ChangeNotifier { final PlatformServices services; @@ -86,55 +49,36 @@ class AppController extends ChangeNotifier { /// 종료 전 확인 (다운로드 중이면 목록 확인). main 에서 연결, 업데이트 설치 때 사용 Future Function()? confirmQuit; - /// 설정 변경 후 저장·적용 /// 위쪽 막대의 CPU · MEM 표시 (2초마다, 보는 화면이 있을 때만 잰다). 지원하지 않으면 null. - late final UsageMonitor? usage = - services.usage == null ? null : UsageMonitor(services.usage!, diskPath: _downloadDisk); + late final UsageMonitor? usage = services.usage == null ? null : UsageMonitor(services.usage!, diskPath: _disk.of); - /// 다운로드 폴더가 있는 드라이브 (예: "M:\\"). 설정이 바뀔 때만 다시 알아낸다. - String? _diskFor, _diskRoot; - String _downloadDisk() { - final key = settings.download.root ?? ''; - if (_diskRoot == null || _diskFor != key) { - _diskFor = key; - try { - _diskRoot = p.rootPrefix(p.absolute(settings.resolvedDownloadRoot())); - } catch (_) { - _diskRoot = ''; - } - } - return _diskRoot!; - } + /// 다운로드 폴더가 있는 드라이브 (설정이 바뀔 때만 다시 알아낸다) + late final _disk = DownloadDisk(() => settings); + + // ───────── 모듈 ───────── + + final _logBook = AppLog(); + late final _jobs = JobQueue(onChanged: notifyListeners, log: _log); + late final _files = SubtitleFiles(services.storage, _tool); + late final _probe = VideoProbe(storage: services.storage, tool: _tool, files: _files, log: _log, fileAccess: () => fileAccess); + late final _ai = AiSubtitleRunner(services: services, files: _files, onChanged: notifyListeners, log: _log); + late final _mkv = MkvBatch(storage: services.storage, tool: _tool, files: _files, onChanged: notifyListeners, log: _log); + late final _download = SubtitleDownload(services.storage, log: _log); + final _encode = EncodeChoice(); + late final VideoListShare _share = VideoListShare(paths: () => [for (final v in videos) v.path], apply: (want) => _list.follow(want)); + late final VideoList _list = VideoList(onChanged: notifyListeners, onSave: () => _share.save(), load: _loadVideo, log: _log); + late final _play = PlaybackPrep(storage: services.storage, shell: services.shell, log: _log); // ───────── 목록 정렬 ───────── - /// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로. - String? sortedBy; - bool sortAscending = true; + /// 마지막으로 누른 정렬 ('name' · 'date') 과 방향 ([VideoList]) + String? get sortedBy => _list.sortedBy; + bool get sortAscending => _list.sortAscending; - /// 동영상 목록을 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬 - void sortVideos(String by) { - sortAscending = sortedBy == by ? !sortAscending : true; - sortedBy = by; - final dates = {}; - if (by == 'date') { - for (final v in videos) { - try { - dates[v] = File(v.path).lastModifiedSync(); - } catch (_) { - dates[v] = DateTime.fromMillisecondsSinceEpoch(0); - } - } - } - videos.sort((a, b) { - var d = by == 'date' ? dates[a]!.compareTo(dates[b]!) : 0; - if (d == 0) d = naturalCompare(a.fileName.toLowerCase(), b.fileName.toLowerCase()); - return sortAscending ? d : -d; - }); - _saveVideoList(); - notifyListeners(); - } + /// 동영상 목록을 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬. 같은 것을 다시 누르면 반대로. + void sortVideos(String by) => _list.sort(by); + /// 설정 변경 후 저장·적용 Future updateSettings(void Function(AppSettings s) change) async { change(settings); _applySettings(); @@ -152,36 +96,33 @@ class AppController extends ChangeNotifier { return ok; } - void _applySettings() { - playerSubtitleScale.value = settings.playback.subtitleScale; - // 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막) - preferredSubtitleSetting = settings.playback.preferredSubtitleLanguage; - // WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다) - DavRegistry.configure(settings.remote.webdavServers); - // 88: SFTP 서버 (sftp:// 경로) - SftpRegistry.configure(settings.remote.sftpServers); - // 88: Android SMB 서버 (smb:// 경로) - SmbRegistry.requireSecure = settings.remote.smbRequireSecure; - // 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기 - RemoteIdle.limit = Duration(seconds: settings.remote.idleSeconds); - SmbRegistry.configure(settings.remote.smbServers); - FtpRegistry.configure(settings.remote.ftpServers); - outputRootOverride = - (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null; - } + void _applySettings() => applyRuntimeSettings(settings); void _saveSettings() => settingsStore?.save(settings); MediaTool get _tool => services.mediaTool; - final List videos = []; - final List logs = []; - VideoItem? selected; + /// 편집 목록 · 보고 있는 동영상 ([VideoList]) + List get videos => _list.items; + List get logs => _logBook.lines; + VideoItem? get selected => _list.selected; + set selected(VideoItem? v) => _list.selected = v; String? ffmpegVersion; - bool busy = false; - /// 화면 크기 · 코덱 · 화질 (모든 동영상에 적용) - EncodeSettings encode = const EncodeSettings(); + /// 무거운 작업 중인지 · 실행 중인 작업 이름 · 대기 중인 작업 이름 ([JobQueue]) + bool get busy => _jobs.busy; + set busy(bool v) => _jobs.busy = v; + String? get currentJob => _jobs.current; + set currentJob(String? v) => _jobs.current = v; + List get pendingJobs => _jobs.pending; + + /// 화면 크기 · 코덱 · 화질 (모든 동영상에 적용 - [EncodeChoice]) + EncodeSettings get encode => _encode.value; + set encode(EncodeSettings e) => _encode.value = e; + + /// 코덱이 저절로 바뀌었을 때 화면에 짧게 알릴 글 (보여 주면 비움) + String? get encodeNotice => _encode.notice; + set encodeNotice(String? v) => _encode.notice = v; /// FFmpeg 가 지원하는 영상 인코더 Set encoders = {}; @@ -200,15 +141,7 @@ class AppController extends ChangeNotifier { settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview'); } catch (_) {} encode = settings.mkv.encode; - aiOptions = AiOptions( - source: languageOf(settings.mkv.aiSource), - targets: { - for (final c in settings.mkv.aiTargets) - if (languageOf(c) != undetermined) languageOf(c), - }.ifEmpty({for (final c in defaultTargetLanguages) languageOf(c)}), - whisper: whisperModels.firstWhere((m) => m.id == settings.mkv.aiWhisper, - orElse: () => whisperModels.first), - ); + aiOptions = AiOptions.fromSaved(source: settings.mkv.aiSource, targets: settings.mkv.aiTargets, whisper: settings.mkv.aiWhisper); _applySettings(); } if (Platform.isWindows) { @@ -217,22 +150,7 @@ class AppController extends ChangeNotifier { } // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를 // 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음) - unawaited(() async { - try { - final hwp = removeStaleHwpWork(await services.storage.tempDirectory()); - if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); - } catch (_) {} - // 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문) - try { - final vault = removeStaleVaultViews((await vaultViewRootOf()).path); - if (vault.isNotEmpty) _log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length])); - } catch (_) {} - // 관리자 지시 (10/21): 꺼내다 강제로 꺼져 받는 폴더에 남은 평문 "이름.jjvtmp" (기록에 있는 것만) - try { - final left = cleanVaultExportJournal((await vaultViewRootOf()).path); - if (left.isNotEmpty) _log(trf('지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개', [left.length])); - } catch (_) {} - }()); + unawaited(cleanLeftovers(services.storage.tempDirectory, _log)); ffmpegVersion = await _tool.version(); if (ffmpegVersion == null) { _log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.')); @@ -246,66 +164,20 @@ class AppController extends ChangeNotifier { bool isCodecAvailable(VideoCodecChoice c) => c == VideoCodecChoice.copy || c.pickEncoder(encoders) != null; - /// 103: 크기 · 보정 때문에 "원본 유지" 에서 H.264 로 저절로 바꿨는지 (원래대로 돌리면 코덱도 원본 유지로 되돌린다) - bool _autoCodec = false; - - /// 코덱이 저절로 바뀌었을 때 화면에 짧게 알릴 글 (보여 주면 비움) - String? encodeNotice; - - /// 크기 · 보정이 모두 원래대로이고 저절로 바꾼 코덱이면 원본 유지로 되돌린다 - EncodeSettings _maybeRestoreCodec(EncodeSettings e) { - if (_autoCodec && e.resolution == ResolutionChoice.original && !e.adjusts && e.codec == VideoCodecChoice.h264) { - _autoCodec = false; - encodeNotice = tr('크기 · 보정을 원래대로 돌려 코덱도 "원본 유지" 로 되돌렸습니다.'); - return e.copyWith(codec: VideoCodecChoice.copy); - } - return e; - } - - void setCodec(VideoCodecChoice c) { - _autoCodec = false; // 사용자가 직접 고름 - // 원본 유지(복사)는 크기 · 화면 비율 · 회전 · 색 보정을 할 수 없으므로 처음으로 - encode = c == VideoCodecChoice.copy - ? encode.resetAdjust().copyWith(codec: c, resolution: ResolutionChoice.original) - : encode.copyWith(codec: c); - _encodeChanged(); - } + void setCodec(VideoCodecChoice c) => _encodeChanged(() => _encode.setCodec(c)); /// 화면 비율 · 회전 · 색 보정 바꾸기 (재인코딩이 필요하면 기본 H.264 로) - void setAdjust(EncodeSettings e) { - if (e.adjusts && e.codec == VideoCodecChoice.copy) { - _autoCodec = true; - encodeNotice = tr('보정은 다시 인코딩해야 해서 코덱을 "원본 유지" 에서 H.264 로 바꿨습니다.'); - encode = e.copyWith(codec: VideoCodecChoice.h264); - } else { - encode = _maybeRestoreCodec(e); - } - _encodeChanged(); - } + void setAdjust(EncodeSettings e) => _encodeChanged(() => _encode.setAdjust(e)); + void setResolution(ResolutionChoice r) => _encodeChanged(() => _encode.setResolution(r)); + void setQuality(QualityChoice q) => _encodeChanged(() => _encode.setQuality(q)); - void _encodeChanged() { + void _encodeChanged(void Function() change) { + change(); settings.mkv.encode = encode; _saveSettings(); notifyListeners(); } - void setResolution(ResolutionChoice r) { - // 크기를 바꾸려면 재인코딩이 필요하므로 기본 H.264 로 - final needCodec = - r != ResolutionChoice.original && encode.codec == VideoCodecChoice.copy; - if (needCodec) { - _autoCodec = true; - encodeNotice = tr('크기를 바꾸면 다시 인코딩해야 해서 코덱을 "원본 유지" 에서 H.264 로 바꿨습니다.'); - } - encode = _maybeRestoreCodec(encode.copyWith(resolution: r, codec: needCodec ? VideoCodecChoice.h264 : null)); - _encodeChanged(); - } - - void setQuality(QualityChoice q) { - encode = encode.copyWith(quality: q); - _encodeChanged(); - } - /// 원본보다 큰 크기로 인코딩하게 되는 동영상 수 int get upscaleCount => videos .where((v) => isUpscale(encode.resolution, v.info?.ofType('video').firstOrNull)) @@ -320,220 +192,45 @@ class AppController extends ChangeNotifier { } /// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다 - Future addVideos(List paths, {bool allowOutputFolder = false}) async { - for (final path in paths) { - if (videos.any((v) => p.equals(v.path, path))) continue; - // jj_mkv 출력 폴더 안의 파일은 제외 (폴더째 넣을 때 만든 결과물이 다시 들어가지 않게) - if (!allowOutputFolder && p.basename(p.dirname(path)) == outputFolderName) { - _log(trf('건너뜀 (출력 폴더의 파일): {0}', [p.basename(path)])); - continue; - } - final v = VideoItem(path); - videos.add(v); - selected ??= v; - notifyListeners(); - await _loadVideo(v); - } - _saveVideoList(); - } + Future addVideos(List paths, {bool allowOutputFolder = false}) => _list.add(paths, allowOutputFolder: allowOutputFolder); - // ───────── 동영상 목록 저장 · 창끼리 공유 ───────── - // - // 목록(경로)을 파일 하나에 적어 둔다. 다시 켜면 그대로 불러오고, 창이 여러 개면 - // 1초마다 파일을 보고 다른 창에서 넣거나 지운 것을 따라 한다. - - String? _videoListFile; - String _videoListSaved = '[]'; - Timer? _videoListTimer; - bool _videoListSyncing = false; - - /// 목록 저장 · 공유 시작 ([file] 을 먼저 불러온다) - Future shareVideoList(String file) async { - _videoListFile = file; - await syncVideoList(); - _videoListTimer?.cancel(); - _videoListTimer = Timer.periodic(const Duration(seconds: 1), (_) => syncVideoList()); - } - - void stopVideoListShare() { - _videoListTimer?.cancel(); - _videoListTimer = null; - } - - void _saveVideoList() { - final file = _videoListFile; - if (file == null || _videoListSyncing) return; - final text = jsonEncode([for (final v in videos) v.path]); - if (text == _videoListSaved) return; - _videoListSaved = text; - try { - // 다른 창이 읽는 중에 반쯤 쓴 파일을 보지 않도록: 임시 파일 → 이름 바꾸기 - final tmp = File('$file.$pid.tmp')..writeAsStringSync(text, flush: true); - tmp.renameSync(file); - } catch (_) {} - } + /// 동영상 목록 저장 · 창끼리 공유 시작 ([file] 을 먼저 불러온다 - [VideoListShare]) + Future shareVideoList(String file) => _share.start(file); + void stopVideoListShare() => _share.stop(); /// 파일이 바뀌었으면 (다른 창에서 넣거나 지움) 이 창의 목록을 맞춘다 - Future syncVideoList() async { - final file = _videoListFile; - if (file == null || _videoListSyncing) return; - String text; - try { - final f = File(file); - if (!f.existsSync()) return; - text = f.readAsStringSync(); - } catch (_) { - return; - } - if (text == _videoListSaved) return; - List want; - try { - want = (jsonDecode(text) as List).cast(); - } catch (_) { - return; - } - _videoListSyncing = true; - try { - _videoListSaved = text; - bool inWant(VideoItem v) => want.any((w) => p.equals(w, v.path)); - // 작업 중인 동영상은 지우지 않는다 - videos.removeWhere((v) => !inWant(v) && v.status != JobStatus.running); - if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; - notifyListeners(); - await addVideos([for (final w in want) if (File(w).existsSync()) w], allowOutputFolder: true); - } finally { - _videoListSyncing = false; - _saveVideoList(); // 없는 파일을 뺐거나 맞추는 동안 이 창에서 바꾼 것이 있으면 다시 적는다 - } - } + Future syncVideoList() => _share.sync(); /// 116 (Android): 저장소 전체 접근 권한이 있는지 (null = 묻지 않음 - Windows) Future Function()? fileAccess; - static String get accessNeededMessage => - tr('"모든 파일에 대한 접근" 권한이 없어 읽을 수 없습니다. 허용하면 저절로 다시 분석합니다.'); - - static bool isPermissionDenied(String message) { - final m = message.toLowerCase(); - return m.contains('permission denied') || m.contains('eacces') || m.contains('operation not permitted'); - } - /// 116: 분석에 실패했던 동영상을 다시 분석한다 (권한을 허용하고 돌아왔을 때). 다시 분석한 수 - Future reanalyzeFailed() async { - var n = 0; - for (final v in [...videos]) { - if (v.info != null || v.message == null || v.status == JobStatus.running) continue; - if (!videos.contains(v)) continue; - v.message = null; - v.subtitles.removeWhere((s) => s.kind == SubtitleKind.embedded); - notifyListeners(); - await _loadVideo(v); - n++; - } - if (n > 0) _log(trf('분석에 실패했던 동영상 {0}개를 다시 분석했습니다', [n])); - return n; - } + Future reanalyzeFailed() => _list.reanalyzeFailed(); Future _loadVideo(VideoItem v) async { - try { - v.info = await _tool.probe(v.path); - // 내장 자막 (mkv 등) - for (final s in v.info!.ofType('subtitle')) { - v.subtitles.add(SubtitleEntry.embedded( - streamIndex: s.index, - codec: s.codec, - language: languageOf(s.language), - title: s.title, - )); - } - } on MediaToolException catch (e) { - // 116: 권한이 없어 못 읽은 것이면 빨간 Permission denied 대신 알아볼 수 있게 (허용하면 다시 분석) - final denied = isPermissionDenied(e.message) && fileAccess != null && !await fileAccess!(); - v.message = denied ? accessNeededMessage : e.message; - _log(trf('분석 실패: {0} - {1}', [v.fileName, e.message])); - } - - // 같은 폴더의 자막 자동 추가 (기능 4) - 다시 분석할 때 이미 있는 것은 그대로 - List folder = const []; - try { - folder = await services.storage.listFiles(v.directory); - } catch (_) {} - final siblings = findSiblingSubtitles(v.path, folder); - for (final d in siblings) { - if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, d.path))) continue; - await _addExternal(v, d.path, d.language); - } - // 전에 만든 자막 (jj_mkv 의 파일명_ko.srt 등: AI 자막 · 번역 · 인터넷 자막) 도 다시 연결 - // (앱을 다시 켜거나 목록에 다시 넣어도 MKV 에 빠지지 않게). 음성인식 원본 파일명_AI.srt 는 언어별 파일과 같아서 뺀다. - var made = 0; - final out = outputDirFor(v.path); - if (!p.equals(out, v.directory)) { - List files = const []; - try { - files = await services.storage.listFiles(out); - } catch (_) {} - for (final d in findSiblingSubtitles(v.path, files)) { - if (d.isAi || d.language == undetermined) continue; - if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, d.path))) continue; - await _addExternal(v, d.path, d.language); - made++; - } - } - final embeddedCount = - v.subtitles.where((s) => s.kind == SubtitleKind.embedded).length; - _log(trf('추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)', [v.fileName, embeddedCount, siblings.length]) + - (made > 0 ? trf(' · 전에 만든 자막 {0}개 (jj_mkv)', [made]) : '')); + await _probe.load(v); notifyListeners(); } - void select(VideoItem v) { - selected = v; - notifyListeners(); - } - - // ───────── 여러 개 선택 (일괄 작업) ───────── + void select(VideoItem v) => _list.select(v); /// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상) - final Set checked = {}; - - void toggleChecked(VideoItem v) { - if (!checked.remove(v)) checked.add(v); - notifyListeners(); - } + Set get checked => _list.checked; + void toggleChecked(VideoItem v) => _list.toggleChecked(v); /// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크 - void toggleAllChecked() { - if (checked.length == videos.length) { - checked.clear(); - } else { - checked - ..clear() - ..addAll(videos); - } - notifyListeners(); - } + void toggleAllChecked() => _list.toggleAllChecked(); /// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개. - List get batchTargets { - checked.retainAll(videos); - if (checked.isNotEmpty) return videos.where(checked.contains).toList(); - return [?selected]; - } + List get batchTargets => _list.batchTargets; /// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다) - void removeChecked() { - final gone = checked.where((v) => v.status != JobStatus.running).toList(); - videos.removeWhere(gone.contains); - checked.removeAll(gone); - if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; - _saveVideoList(); - notifyListeners(); - } + void removeChecked() => _list.removeChecked(); /// [targets] 의 AI 자막을 만들고, 이어서 그 동영상들만 MKV 로 만든다 (자막 만들기를 취소하면 거기서 멈춤) Future aiThenBuild(List targets, AiOptions opts, {String? onExisting}) async { await generateAiSubtitles(targets, opts); - if (_aiCancelled) return; + if (_ai.cancelled) return; await buildVideos(targets, onExisting: onExisting); } @@ -548,166 +245,28 @@ class AppController extends ChangeNotifier { return n; } - /// 동영상을 [dest] (환경 설정의 이동 버튼 폴더) 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 과 - /// 만든 결과물 (jj_mkv 의 a.mkv · a_AI.srt · a_ko.srt …) 도 함께 (결과물은 이동 폴더의 jj_mkv 로). - /// 옮긴 동영상은 목록에서 뺀다. 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들) - Future<(int, List)> moveVideos(List targets, String dest) async { - if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]); - var moved = 0; - final errors = []; - try { - await Directory(dest).create(recursive: true); - } catch (e) { - return (0, [trf('이동할 폴더를 만들 수 없습니다: {0} ({1})', [dest, e])]); - } - for (final v in List.of(targets)) { - if (v.status == JobStatus.running) { - errors.add(trf('{0}: 작업 중이라 옮기지 않았습니다', [v.fileName])); - continue; - } - if (p.equals(v.directory, dest)) { - errors.add(trf('{0}: 이미 이동 폴더에 있습니다', [v.fileName])); - continue; - } - // 옮기기 시작하면 바로 목록에서 뺀다 (다른 드라이브 · NAS 로는 복사라 큰 동영상은 몇 초 넘게 남아 보였다). - // 옮기지 못하면 원래 자리 · 표시 · 선택으로 되돌린다. - final at = videos.indexOf(v); - final wasChecked = checked.contains(v); - final wasSelected = selected == v; - videos.remove(v); - checked.remove(v); - if (wasSelected) selected = videos.elementAtOrNull(at) ?? videos.lastOrNull; - notifyListeners(); - try { - final side = { - for (final s in v.subtitles) - if (s.path != null && - p.equals(p.dirname(s.path!), v.directory) && - p.basename(s.path!).startsWith('${v.baseName}.') && - File(s.path!).existsSync()) - s.path!, - }; - final to = await moveFileInto(v.path, dest); - for (final s in side) { - try { - await moveFileInto(s, dest); - } catch (e) { - _log(trf('자막 이동 실패: {0} ({1})', [p.basename(s), e])); - } - } - final results = await _moveResults(v.path, to); - _log(trf('이동: {0} → {1}{2}', [ - v.fileName, - to, - [ - if (side.isNotEmpty) trf(' (자막 {0}개 함께)', [side.length]), - if (results > 0) trf(' (결과물 {0}개 함께)', [results]), - ].join(), - ])); - moved++; - } catch (e) { - errors.add('${v.fileName}: $e'); - if (File(v.path).existsSync() && !videos.contains(v)) { - videos.insert(at.clamp(0, videos.length), v); - if (wasChecked) checked.add(v); - if (wasSelected) selected = v; - notifyListeners(); - } - } - } - if (selected != null && !videos.contains(selected)) selected = videos.firstOrNull; - _saveVideoList(); - notifyListeners(); - return (moved, errors); - } + /// 동영상을 [dest] (환경 설정의 이동 버튼 폴더) 로 옮긴다 - 옆의 같은 이름 자막 · 만든 결과물도 함께 ([VideoList.move]). + /// 작업 중인 것은 옮기지 않는다. 반환: (옮긴 수, 못 옮긴 이유들) + Future<(int, List)> moveVideos(List targets, String dest) => _list.move(targets, dest); - /// 만든 결과물 (jj_mkv 의 이름.mkv · 이름_AI.srt · 이름_ko.srt …) 을 옮긴 동영상의 jj_mkv 로. 옮긴 개수. - Future _moveResults(String oldVideo, String newVideo) async { - final from = outputDirFor(oldVideo), to = outputDirFor(newVideo); - if (p.equals(from, to) || !Directory(from).existsSync()) return 0; - final stem = p.basenameWithoutExtension(oldVideo); - final mine = RegExp('^${RegExp.escape(stem)}(\\.mkv|_[A-Za-z-]+\\.(srt|ass|ssa|smi|vtt|sub))\$', caseSensitive: false); - var n = 0; - for (final f in Directory(from).listSync().whereType()) { - if (!mine.hasMatch(p.basename(f.path))) continue; - try { - await Directory(to).create(recursive: true); - await moveFileInto(f.path, to); - n++; - } catch (e) { - _log(trf('결과물 이동 실패: {0} ({1})', [p.basename(f.path), e])); - } - } - return n; - } - - /// 파일을 폴더 안으로 옮긴다 (같은 이름이 있으면 "이름 (2).mp4"). 다른 드라이브 · SD 카드면 복사 후 지운다. 옮긴 경로를 돌려준다. - static Future moveFileInto(String from, String dir) async { - final stem = p.basenameWithoutExtension(from), ext = p.extension(from); - var to = p.join(dir, p.basename(from)); - for (var n = 2; File(to).existsSync(); n++) { - to = p.join(dir, '$stem ($n)$ext'); - } - try { - await File(from).rename(to); - } on FileSystemException { - try { - await File(from).copy(to); - await File(from).delete(); - } catch (_) { - // 원본을 지우지 못하면 (열려 있음 등) 옮긴 것이 아니다 - 방금 만든 사본을 지워 두 벌이 남지 않게 (원본은 그대로) - try { - if (File(from).existsSync()) await File(to).delete(); - } catch (_) {} - rethrow; - } - } - return to; - } - - void removeVideo(VideoItem v) { - if (v.status == JobStatus.running) return; - videos.remove(v); - checked.remove(v); - if (selected == v) selected = videos.isEmpty ? null : videos.first; - _saveVideoList(); - notifyListeners(); - } + void removeVideo(VideoItem v) => _list.remove(v); void clearVideos() { - if (busy) return; - videos.clear(); - checked.clear(); - selected = null; - _saveVideoList(); - notifyListeners(); + if (!busy) _list.clear(); } // ───────── 자막 ───────── Future pickSubtitlesFor(VideoItem v) async { - final paths = - await services.storage.pickSubtitles(initialDirectory: v.directory); + final paths = await services.storage.pickSubtitles(initialDirectory: v.directory); for (final path in paths) { - if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) { - continue; - } + if (v.subtitles.any((s) => s.path != null && p.equals(s.path!, path))) continue; final d = findSiblingSubtitles(v.path, [path]); - await _addExternal(v, path, d.isEmpty ? undetermined : d.first.language); + v.subtitles.add(await _files.external(path, d.isEmpty ? undetermined : d.first.language)); } notifyListeners(); } - Future _addExternal(VideoItem v, String path, Language lang) async { - String charset = 'UTF-8'; - try { - final head = await services.storage.readHead(path, 64 * 1024); - charset = detectCharset(Uint8List.fromList(head)); - } catch (_) {} - v.subtitles.add(SubtitleEntry.external( - path: path, language: lang, charset: charset)); - } - /// 내장 자막은 '삭제' 표시(출력에서 제외), 외부 자막은 목록에서 제거 void removeSubtitle(VideoItem v, SubtitleEntry s) { if (s.kind == SubtitleKind.embedded) { @@ -735,154 +294,33 @@ class AppController extends ChangeNotifier { !(s.kind == SubtitleKind.embedded && bitmapSubtitleCodecs.contains(s.codec)) && (s.kind == SubtitleKind.external || ffmpegVersion != null); - /// 자막을 큐 목록으로 불러온다. - /// - 외부 SRT: 지정 문자셋으로 직접 읽음 - /// - 외부 SMI/ASS/VTT, 내장 트랙: FFmpeg 로 UTF-8 SRT 변환 후 읽음 - Future> loadCues(VideoItem v, SubtitleEntry s) async { - final storage = services.storage; - if (s.kind == SubtitleKind.external && s.codec == 'srt') { - final bytes = await storage.readBytes(s.path!); - return parseSrt(decodeText(bytes, s.charset ?? 'UTF-8')); - } - final tmp = p.join(await storage.tempDirectory(), - 'edit_${DateTime.now().microsecondsSinceEpoch}.srt'); - final copy = await _utf8Copy(s); - try { - await _tool.runFfmpeg(s.kind == SubtitleKind.external - ? buildToSrtArgs(input: copy ?? s.path!, output: tmp, charset: copy == null ? s.charset : null) - : buildToSrtArgs(input: v.path, output: tmp, streamIndex: s.streamIndex)); - return parseSrt(decodeText(await storage.readBytes(tmp), 'UTF-8')); - } finally { - await storage.delete(tmp); - if (copy != null) await storage.delete(copy); - } - } + /// 자막을 큐 목록으로 불러온다 ([SubtitleFiles.loadCues]) + Future> loadCues(VideoItem v, SubtitleEntry s) => _files.loadCues(v, s); - /// UTF-8 이 아닌 외부 자막 (CP949 SMI 등) 의 UTF-8 임시 사본. 필요 없으면 null. - /// Android 의 FFmpeg (ffmpeg-kit) 에는 문자셋 변환 (iconv) 이 없어 -sub_charenc 를 쓸 수 없으므로 - /// 앱이 바꿔서 넘긴다 (Windows 도 같은 방식으로). - Future _utf8Copy(SubtitleEntry s) async { - final cs = s.charset; - if (s.kind != SubtitleKind.external || s.path == null || cs == null || cs.startsWith('UTF-')) return null; - final storage = services.storage; - final text = decodeText(await storage.readBytes(s.path!), cs); - final copy = p.join(await storage.tempDirectory(), - 'utf8_${DateTime.now().microsecondsSinceEpoch}_${p.basename(s.path!)}'); - await storage.writeBytes(copy, utf8.encode(text)); - return copy; - } - - /// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다. - /// - 내장 자막: 원래 트랙은 '삭제' 표시, 편집본을 외부 자막으로 추가 - /// - 외부 자막: 같은 자리의 항목을 편집본으로 교체 (원본 파일은 그대로 둠) + /// 편집한 자막을 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 에 반영되도록 목록을 바꾼다 ([SubtitleFiles.saveEdited]). /// 돌려주는 값: (저장 경로, 표현할 수 없어 '?' 로 바뀐 글자 수) - Future<(String, int)> saveEdited( - VideoItem v, SubtitleEntry s, List cues, String charset) async { - final enc = encodeText(formatSrt(cues), charset); - final target = await _editedPath(v, s); - await services.storage.writeBytes(target, enc.bytes); - - final edited = SubtitleEntry.external( - path: target, - language: s.language, - charset: charset == 'UTF-8-BOM' ? 'UTF-8' : charset, - title: s.title, - ); - final i = v.subtitles.indexOf(s); - if (s.kind == SubtitleKind.embedded) { - s.enabled = false; - v.subtitles.insert(i + 1, edited); - } else { - v.subtitles[i] = edited; - } - if (v.status == JobStatus.done) v.status = JobStatus.ready; - _log(trf('자막 저장: {0} ({1}, {2}개 줄)' '{3}', [p.basename(target), charset, cues.length, enc.lostChars > 0 ? trf(' ⚠ 표현할 수 없는 글자 {0}개', [enc.lostChars]) : ''])); + Future<(String, int)> saveEdited(VideoItem v, SubtitleEntry s, List cues, String charset) async { + final (target, lost) = await _files.saveEdited(v, s, cues, charset); + _log(trf('자막 저장: {0} ({1}, {2}개 줄)' '{3}', [p.basename(target), charset, cues.length, lost > 0 ? trf(' ⚠ 표현할 수 없는 글자 {0}개', [lost]) : ''])); notifyListeners(); - return (target, enc.lostChars); + return (target, lost); } /// 다른 이름으로 저장 (목록은 바꾸지 않음) - Future<(String?, int)> saveCuesAs( - VideoItem v, List cues, String charset, String fileName) async { + Future<(String?, int)> saveCuesAs(VideoItem v, List cues, String charset, String fileName) async { final enc = encodeText(formatSrt(cues), charset); - final path = await services.storage.saveAs( - fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory); + final path = await services.storage.saveAs(fileName: fileName, bytes: enc.bytes, initialDirectory: v.directory); if (path != null) _log(trf('다른 이름으로 저장: {0} ({1})', [path, charset])); return (path, enc.lostChars); } - Future _editedPath(VideoItem v, SubtitleEntry s) async { - // 이미 편집본(jj_mkv 안)을 다시 편집하면 같은 파일에 덮어쓴다 - if (s.kind == SubtitleKind.external && - p.equals(p.dirname(s.path!), outputDirFor(v.path)) && - p.extension(s.path!).toLowerCase() == '.srt') { - return s.path!; - } - final base = languageSubtitlePath(v.path, s.language); - final used = v.subtitles - .where((e) => e != s && e.path != null) - .map((e) => p.normalize(e.path!).toLowerCase()) - .toSet(); - var candidate = base; - for (var n = 2; used.contains(p.normalize(candidate).toLowerCase()); n++) { - candidate = '${p.withoutExtension(base)}_$n.srt'; - } - return candidate; - } + // ───────── AI 자막 (4단계) · 자막 번역 ([AiSubtitleRunner]) ───────── - // ───────── AI 자막 (4단계) ───────── - - bool get aiAvailable => - services.createRecognizer != null && services.createTranslator != null; + bool get aiAvailable => services.createRecognizer != null && services.createTranslator != null; /// 마지막으로 고른 AI 자막 설정 AiOptions aiOptions = AiOptions.defaults(); - Translator? _translator; - bool _aiCancelled = false; - - // ───────── 작업 대기열 ───────── - - /// 실행 중인 작업 이름 (없으면 null) - String? currentJob; - - /// 대기 중인 작업 이름 (차례대로) - final List pendingJobs = []; - final List<(Future Function(), Completer)> _pendingRuns = []; - - /// 작업 중이면 대기열에 넣고, 아니면 바로 실행한다. 끝나면 다음 작업을 이어서 실행. - /// (AI 자막 · 자막 번역 · MKV 만들기 는 CPU · 메모리를 많이 써서 하나씩) - /// 돌려주는 Future 는 그 작업이 실제로 끝날 때 (취소로 빠지면 바로) 끝난다. - Future _enqueue(String label, Future Function() job) { - if (busy) { - final done = Completer(); - pendingJobs.add(label); - _pendingRuns.add((job, done)); - _log(trf('대기열에 추가: {0} (대기 {1}개)', [label, pendingJobs.length])); - notifyListeners(); - return done.future; - } - return _run(label, job); - } - - Future _run(String label, Future Function() job) async { - busy = true; - currentJob = label; - notifyListeners(); - try { - await job(); - } finally { - busy = false; - currentJob = null; - notifyListeners(); - if (_pendingRuns.isNotEmpty) { - final (next, done) = _pendingRuns.removeAt(0); - final nextLabel = pendingJobs.removeAt(0); - unawaited(_run(nextLabel, next).then((_) => done.complete(), onError: done.completeError)); - } - } - } - /// [targets] 동영상들의 AI 자막 만들기 (하나씩 차례로). 다른 작업 중이면 대기열로. Future generateAiSubtitles(List targets, AiOptions opts) async { if (!aiAvailable || ffmpegVersion == null || targets.isEmpty) return; @@ -898,287 +336,11 @@ class AppController extends ChangeNotifier { } } final label = targets.length == 1 ? trf('AI 자막: {0}', [targets.first.fileName]) : trf('AI 자막 {0}개', [targets.length]); - return _enqueue(label, () => _usingAiModels(() => _runAi(targets, opts))); + return _jobs.enqueue(label, () => _ai.usingModels(() => _ai.run(targets, opts))); } /// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게) - bool get aiModelsInUse => _aiModelUsers > 0; - int _aiModelUsers = 0; - - Future _usingAiModels(Future Function() job) async { - _aiModelUsers++; - notifyListeners(); - try { - await job(); - } finally { - _aiModelUsers--; - notifyListeners(); - } - } - - Future _runAi(List targets, AiOptions opts) async { - _aiCancelled = false; - try { - // 1. 모델 준비 (없으면 내려받기) - // 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음 - for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) { - await _ensureAiModel(m, targets); - } - for (final v in targets) { - if (_aiCancelled) break; - await _generateOne(v, opts); - } - } catch (e) { - _log(trf('AI 자막 중단: {0}', [e])); - for (final v in targets.where((v) => v.status == JobStatus.running)) { - v - ..status = JobStatus.failed - ..message = '$e' - ..phase = null; - } - } finally { - await _translator?.dispose(); - _translator = null; - notifyListeners(); - } - } - - /// AI 자막 모델이 없으면 내려받기 (진행은 [targets] 의 줄에) - Future _ensureAiModel(ModelSpec m, List targets) async { - if (await services.models.isInstalled(m)) return; - _log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel])); - await services.models.download(m, - isCancelled: () => _aiCancelled, - onProgress: (x) { - for (final v in targets) { - v - ..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()]) - ..progress = x - ..status = JobStatus.running; - } - notifyListeners(); - }); - } - - Future _generateOne(VideoItem v, AiOptions opts) async { - void phase(String text, double p) { - v - ..status = JobStatus.running - ..phase = text - ..progress = p; - notifyListeners(); - } - - final storage = services.storage; - final tmp = p.join(await storage.tempDirectory(), - 'ai_${DateTime.now().microsecondsSinceEpoch}'); - await storage.ensureDirectory(tmp); - final wav = p.join(tmp, 'audio.wav'); - _log(trf('AI 자막 시작: {0}', [v.fileName])); - try { - if (!await storage.exists(v.path)) throw MediaToolException(missingFileMessage); - // 2. 음성 추출 (전체의 5%) - if (v.info?.ofType('audio').isEmpty ?? false) { - throw MediaToolException(tr('음성 트랙이 없습니다.')); - } - phase(tr('음성 추출 중'), 0); - await _tool.runFfmpeg(buildExtractAudioArgs(v.path, wav), - duration: v.info?.duration, onProgress: (x) => phase(tr('음성 추출 중'), x * 0.05)); - if (_aiCancelled) throw const AiCancelled(); - - // 3. 음성인식 (5% ~ 50%) - phase(tr('음성인식 중'), 0.05); - final recognizer = _recognizer = services.createRecognizer!(); - if (_aiCancelled) throw const AiCancelled(); - final raw = await recognizer.transcribe( - wav, - modelPath: await services.models.pathOf(opts.whisper), - language: opts.source == undetermined ? 'auto' : whisperCode(opts.source), - onProgress: (x) => phase(trf('음성인식 중 {0}%', [(x * 100).round()]), 0.05 + x * 0.45), - ); - final cues = cleanRecognized(raw); - if (cues.isEmpty) throw MediaToolException(tr('인식된 말이 없습니다.')); - if (_aiCancelled) throw const AiCancelled(); - - // 4. 원어 - final src = opts.source != undetermined - ? opts.source - : detectLanguage(cues.map((c) => c.text).join(' ')); - _log(trf('음성인식 완료: {0}줄, 원어 {1}({2})', [cues.length, src.name, src.code])); - // 198: 자동 감지가 틀릴 수 있다 (영어를 한국어로 잡아 영어 발음을 한글로 적음) - 끝난 뒤 알리고 다른 언어로 다시 할 수 있게 - v.aiDetected = opts.source == undetermined ? src.code : null; - - // 5. 파일명_AI.srt (원본) - await storage.ensureDirectory(outputDirFor(v.path)); - await storage.writeBytes( - aiSubtitlePath(v.path), encodeText(formatSrt(cues), 'UTF-8').bytes); - - // 6. 언어별 파일명_코드.srt (원어도 따로 만듦) → MKV 에 추가 - final langs = opts.targets.toList(); - for (var i = 0; i < langs.length; i++) { - final tgt = langs[i]; - final base = 0.5 + 0.5 * i / langs.length; - final span = 0.5 / langs.length; - List out; - if (tgt.code == src.code || src == undetermined) { - out = [for (final c in cues) c.copy()]; - } else { - if (_translator == null) { - // 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다 - await _ensureAiModel(nllbModel, [v]); - if (_aiCancelled) throw const AiCancelled(); - phase(tr('번역 모델 불러오는 중'), base); - _translator = services.createTranslator!(); - await _translator!.load(await services.models.folderOf(nllbModel)); - } - phase(trf('{0} 번역 중', [tgt.name]), base); - final texts = await translateKeepingFillers( - [for (final c in cues) c.text.replaceAll('\n', ' ')], - (rest) => _translator!.translate( - rest, - source: src.nllb, - target: tgt.nllb, - onProgress: (x) => phase(trf('{0} 번역 중 {1}%', [tgt.name, (x * 100).round()]), base + span * x), - ), - src: src.code, - tgt: tgt.code, - ); - out = [ - for (var k = 0; k < cues.length; k++) - Cue(cues[k].start, cues[k].end, cleanRecognized([Cue(cues[k].start, cues[k].end, texts[k])]).firstOrNull?.text ?? texts[k]), - ]; - } - final path = languageSubtitlePath(v.path, tgt); - await storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes); - _addOrReplaceExternal(v, path, tgt, tgt.code == src.code ? tr('AI 인식') : tr('AI 번역')); - _log(trf('저장: {0}', [p.basename(path)])); - } - v - ..status = JobStatus.ready - ..phase = null - ..progress = 0; - // 196: 경로는 기기의 구분자로 (Android 에서도 "jj_mkv\이름" 으로 보였다) - _log(trf('AI 자막 완료: {0} → {1} 외 {2}개', [v.fileName, p.join(outputFolderName, '${v.baseName}_AI.srt'), langs.length])); - } on AiCancelled { - v - ..status = JobStatus.ready - ..phase = null - ..progress = 0; - _aiCancelled = true; - _log(trf('AI 자막 취소: {0}', [v.fileName])); - } catch (e) { - v - ..status = JobStatus.failed - ..phase = null - ..message = e is MediaToolException ? e.message : '$e'; - _log(trf('AI 자막 실패: {0}\n{1}', [v.fileName, v.message])); - } finally { - // 작업 폴더째 지운다 (음성인식이 만드는 변환 사본 audio.wav.wav 도 함께) - try { - await Directory(tmp).delete(recursive: true); - } catch (_) { - await storage.delete(wav); - } - notifyListeners(); - } - } - - void _addOrReplaceExternal(VideoItem v, String path, Language lang, String title) { - final entry = SubtitleEntry.external( - path: path, language: lang, charset: 'UTF-8', title: '${lang.name} ($title)'); - final i = v.subtitles.indexWhere((s) => s.path != null && p.equals(s.path!, path)); - if (i >= 0) { - v.subtitles[i] = entry; - } else { - v.subtitles.add(entry); - } - } - - // ───────── 인터넷 자막 (5단계) ───────── - - late final List subtitleProviders = - services.createSubtitleProviders?.call(() => settings) ?? const []; - - SubtitleProvider? get subtitleProvider => - subtitleProviders.isEmpty ? null : subtitleProviders.first; - - /// 파일명으로 검색 조건 추정 + 영상 해시 계산 - Future guessSubtitleQuery(VideoItem v, List langs) async { - final q = guessQuery(v.path, languages: langs); - String? hash; - try { - final s = services.storage; - final size = await s.fileSize(v.path); - const chunk = 65536; - if (size >= chunk) { - hash = openSubtitlesHash(size, await s.readRange(v.path, 0, chunk), - await s.readRange(v.path, size - chunk, chunk)); - } - } catch (_) {} - return SubtitleQuery( - title: q.title, year: q.year, season: q.season, episode: q.episode, - movieHash: hash, languages: langs); - } - - Future> searchSubtitles(SubtitleQuery q) async { - final p0 = subtitleProvider; - if (p0 == null) return const []; - final r = await p0.search(q); - _log(trf('자막 검색 ({0}): "{1}"{2} → {3}개', [p0.name, q.title, q.season != null ? ' S${q.season}E${q.episode}' : '', r.length])); - return r; - } - - /// 고른 자막 받기 → jj_mkv\파일명_언어코드.srt (UTF-8) 저장 → MKV 자막에 추가 - /// 돌려주는 값: 받은 개수 - /// [translateTo] 를 주면 받은 자막을 그 언어로 번역해 함께 추가 (대기열, 기다리지 않음) - Future downloadSubtitles(VideoItem v, List picks, {Language? translateTo}) async { - final provider = subtitleProvider; - if (provider == null) return 0; - final storage = services.storage; - final dir = outputDirFor(v.path); - await storage.ensureDirectory(dir); - final used = { - for (final f in await storage.listFiles(dir)) p.basename(f).toLowerCase(), - for (final s in v.subtitles) - if (s.path != null) p.basename(s.path!).toLowerCase(), - }; - var ok = 0; - var queuedTranslate = false; - for (final r in picks) { - try { - final bytes = await provider.download(r); - final text = decodeText(bytes, detectCharset(bytes)); - final cues = parseSrt(text); - if (cues.isEmpty) throw SubtitleProviderException(tr('SRT 형식이 아니거나 비어 있습니다.')); - final name = nextFreeName('${v.baseName}_${r.language.code}.srt', used); - used.add(name.toLowerCase()); - final path = p.join(dir, name); - await storage.writeBytes(path, encodeText(formatSrt(cues), 'UTF-8').bytes); - final entry = SubtitleEntry.external( - path: path, language: r.language, charset: 'UTF-8', - title: '${r.language.name} (${provider.name})'); - v.subtitles.add(entry); - ok++; - if (translateTo != null && - !queuedTranslate && - r.language.code != translateTo.code && - !v.subtitles.any((x) => x.enabled && x.language.code == translateTo.code)) { - queuedTranslate = true; // 한 번만 (여러 언어를 받아도 첫 자막에서 번역) - unawaited(translateSubtitle(v, entry, {translateTo})); - } - _log(trf('자막 받음: {0} ← {1}', [name, r.release])); - } catch (e) { - _log(trf('자막 받기 실패: {0}\n{1}', [r.release, e])); - if (e is SubtitleProviderException && e.quotaExceeded) break; - } - notifyListeners(); - } - if (v.status == JobStatus.done && ok > 0) v.status = JobStatus.ready; - notifyListeners(); - return ok; - } - - // ───────── 자막 번역 (있는 자막 → 다른 언어) ───────── + bool get aiModelsInUse => _ai.modelsInUse; /// 자막 [s] 를 [targets] 언어로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 자막에 추가. /// 원어는 자막의 언어 태그, 없으면 글자로 추정. 이 PC 안에서 NLLB 로 번역 (다른 작업 중이면 대기열로). @@ -1186,97 +348,43 @@ class AppController extends ChangeNotifier { if (services.createTranslator == null || targets.isEmpty) return; if (busy) v.phase = tr('자막 번역 대기 중'); notifyListeners(); - return _enqueue(trf('자막 번역: {0} → {1}', [v.fileName, targets.map((t) => t.code).join('/')]), - () => _usingAiModels(() => _runTranslate(v, s, targets))); + return _jobs.enqueue(trf('자막 번역: {0} → {1}', [v.fileName, targets.map((t) => t.code).join('/')]), + () => _ai.usingModels(() => _ai.translate(v, s, targets))); } - Future _runTranslate(VideoItem v, SubtitleEntry s, Set targets) async { - _aiCancelled = false; - void phase(String t, double p) { - v - ..status = JobStatus.running - ..phase = t - ..progress = p; - notifyListeners(); - } + // ───────── 인터넷 자막 (5단계 - [SubtitleDownload]) ───────── - Translator? translator; - try { - phase(tr('자막 불러오는 중'), 0); - final cues = await loadCues(v, s); - if (cues.isEmpty) throw MediaToolException(tr('자막이 비어 있습니다.')); - final src = s.language != undetermined - ? s.language - : detectLanguage(cues.take(200).map((c) => c.text).join(' ')); - if (src == undetermined) throw MediaToolException(tr('자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.')); - final langs = targets.where((t) => t.code != src.code).toList(); - if (langs.isEmpty) throw MediaToolException(trf('원어({0})와 같은 언어로는 번역하지 않습니다.', [src.name])); + late final List subtitleProviders = + services.createSubtitleProviders?.call(() => settings) ?? const []; - if (!await services.models.isInstalled(nllbModel)) { - _log(trf('번역 모델 내려받는 중: {0}', [nllbModel.sizeLabel])); - await services.models.download(nllbModel, - isCancelled: () => _aiCancelled, onProgress: (x) => phase(trf('번역 모델 내려받는 중 {0}%', [(x * 100).round()]), x * 0.2)); + SubtitleProvider? get subtitleProvider => subtitleProviders.isEmpty ? null : subtitleProviders.first; + + /// 파일명으로 검색 조건 추정 + 영상 해시 계산 + Future guessSubtitleQuery(VideoItem v, List langs) => _download.queryFor(v, langs); + + Future> searchSubtitles(SubtitleQuery q) async { + final p0 = subtitleProvider; + return p0 == null ? const [] : _download.search(p0, q); + } + + /// 고른 자막 받기 → jj_mkv\파일명_언어코드.srt (UTF-8) 저장 → MKV 자막에 추가. 돌려주는 값: 받은 개수 + /// [translateTo] 를 주면 받은 자막을 그 언어로 번역해 함께 추가 (대기열, 기다리지 않음) + Future downloadSubtitles(VideoItem v, List picks, {Language? translateTo}) async { + final provider = subtitleProvider; + if (provider == null) return 0; + var queuedTranslate = false; + final ok = await _download.download(v, picks, provider, onAdded: (entry, r) { + if (translateTo != null && + !queuedTranslate && + r.language.code != translateTo.code && + !v.subtitles.any((x) => x.enabled && x.language.code == translateTo.code)) { + queuedTranslate = true; // 한 번만 (여러 언어를 받아도 첫 자막에서 번역) + unawaited(translateSubtitle(v, entry, {translateTo})); } - phase(tr('번역 모델 불러오는 중'), 0.2); - translator = _translator = services.createTranslator!(); - await translator.load(await services.models.folderOf(nllbModel)); - - final dir = outputDirFor(v.path); - await services.storage.ensureDirectory(dir); - final used = { - for (final f in await services.storage.listFiles(dir)) p.basename(f).toLowerCase(), - for (final e in v.subtitles) - if (e.path != null) p.basename(e.path!).toLowerCase(), - }; - for (var i = 0; i < langs.length; i++) { - if (_aiCancelled) throw const AiCancelled(); - final tgt = langs[i]; - final base = 0.25 + 0.75 * i / langs.length; - final span = 0.75 / langs.length; - final texts = await translateKeepingFillers( - [for (final c in cues) c.text.replaceAll('\n', ' ')], - (rest) => translator!.translate( - rest, - source: src.nllb, - target: tgt.nllb, - onProgress: (x) => phase(trf('{0} → {1} 번역 {2}%', [src.name, tgt.name, (x * 100).round()]), base + span * x), - ), - src: src.code, - tgt: tgt.code, - ); - final out = [ - for (var k = 0; k < cues.length; k++) - Cue(cues[k].start, cues[k].end, - cleanRecognized([Cue(cues[k].start, cues[k].end, texts[k])]).firstOrNull?.text ?? texts[k]), - ]; - final name = nextFreeName('${v.baseName}_${tgt.code}.srt', used); - used.add(name.toLowerCase()); - final path = p.join(dir, name); - await services.storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes); - v.subtitles.add(SubtitleEntry.external( - path: path, language: tgt, charset: 'UTF-8', title: trf('{0} (AI 번역 ← {1})', [tgt.name, src.code]))); - _log(trf('자막 번역 저장: {0} ({1} → {2}, {3}줄)', [name, src.name, tgt.name, cues.length])); - } - v - ..status = JobStatus.ready - ..phase = null - ..progress = 0; - } on AiCancelled { - v - ..status = JobStatus.ready - ..phase = null; - _log(trf('자막 번역 취소: {0}', [v.fileName])); - } catch (e) { - v - ..status = JobStatus.failed - ..phase = null - ..message = e is MediaToolException ? e.message : '$e'; - _log(trf('자막 번역 실패: {0}\n{1}', [v.fileName, v.message])); - } finally { - await translator?.dispose(); - _translator = null; notifyListeners(); - } + }); + notifyListeners(); + return ok; } // ───────── 동영상 재생 ───────── @@ -1287,349 +395,89 @@ class AppController extends ChangeNotifier { /// [internal]: 외부 프로그램 설정과 상관없이 내장 플레이어로 (파일 탐색기의 "내장 플레이어로 재생") /// [external]: 외부 프로그램으로 열 때 (화면이 WebDAV 로그인 안내 등을 한다 - 55). 없으면 바로 연다 Future<(List, int)?> preparePlayback(List files, - {bool keepOrder = false, - bool internal = false, - Future Function(String program, List videos)? external}) async { - // 29: 음악도 내장 플레이어로 (탐색기 메뉴의 "내장 플레이어로 재생") - final videos = files.where((f) => isVideoFile(f) || isAudioFile(f)).toList(); - if (videos.isEmpty) return null; - final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); - final program = settings.playback.externalPlayers[ext]; - if (!internal && program != null && program.isNotEmpty) { - // WebDAV 는 스트리밍 주소로 넘긴다 (아이디 · 비밀번호는 넣지 않음 - 55) - if (external != null) { - await external(program, videos); - } else { - await services.shell.openExternal(program, videos.map(vPlayable).toList()); - } - _log(trf('외부 프로그램으로 재생: {0} ← {1}개', [program == 'system' ? tr('기본 연결 프로그램') : p.basename(program), videos.length])); - return null; - } - if (videos.length > 1) { - if (!keepOrder) videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b))); - return (videos, 0); - } - final siblings = isDav(videos.first) - ? [for (final e in await vList(vDirname(videos.first))) if (!e.isDir) e.path] - : await services.storage.listFiles(p.dirname(videos.first)); - return buildPlaylist(videos.first, siblings, settings.playback.playlistMode); - } + {bool keepOrder = false, + bool internal = false, + Future Function(String program, List videos)? external}) => + _play.prepare(files, settings.playback, keepOrder: keepOrder, internal: internal, external: external); + + /// 플레이어에 넘길 자막 파일 (Android 는 UTF-8 · SRT 사본 - [SubtitleFiles.playable]) + Future playableSubtitle(String path) => _files.playable(path, android: Platform.isAndroid); /// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt - /// 플레이어에 넘길 자막 파일 (Android). Android 의 mpv 는 문자셋 변환 (iconv) 이 없고 SAMI (SMI) 도 열지 못하므로 - /// UTF-8 이 아닌 자막은 UTF-8 사본으로, SMI 는 SRT 로 바꿔 임시 폴더에 만든다. 그 밖에는 그대로. - Future playableSubtitle(String path) async { - if (!Platform.isAndroid) return path; - try { - final storage = services.storage; - final ext = p.extension(path).toLowerCase(); - final isSami = ext == '.smi' || ext == '.sami'; - final cs = detectCharset(Uint8List.fromList(await storage.readHead(path, 64 * 1024))); - if (cs == 'UTF-8' && !isSami) return path; - final tmp = await storage.tempDirectory(); - final key = path.hashCode.toUnsigned(32); - var src = path; - if (cs != 'UTF-8') { - src = p.join(tmp, 'play_${key}_${p.basename(path)}'); - await storage.writeBytes(src, utf8.encode(decodeText(await storage.readBytes(path), cs))); - } - if (!isSami) return src; - final srt = p.join(tmp, 'play_$key.srt'); - await _tool.runFfmpeg(buildToSrtArgs(input: src, output: srt)); - return srt; - } catch (_) { - return path; - } - } + Future> externalSubtitlesFor(String video) => _files.externalFor(video); - Future> externalSubtitlesFor(String video) async { - final out = []; - final storage = services.storage; - if (isDav(video)) { - // WebDAV: 같은 폴더의 자막만 (작으므로 임시 폴더로 받아서 넘긴다) - try { - final files = [for (final e in await vList(vDirname(video))) if (!e.isDir) e.path]; - final temp = await storage.tempDirectory(); - for (final d in findSiblingSubtitles(video, files)) { - out.add(await vLocalCopy(d.path, temp)); - } - } catch (_) {} - return out; - } - out.addAll(findSiblingSubtitles(video, await storage.listFiles(p.dirname(video))).map((d) => d.path)); - final base = p.basenameWithoutExtension(video).toLowerCase(); - for (final f in await storage.listFiles(outputDirFor(video))) { - final n = p.basename(f).toLowerCase(); - if (n.startsWith('${base}_') && subtitleExtensions.contains(p.extension(n).replaceFirst('.', ''))) { - out.add(f); - } - } - return out; - } - - // ───────── MKV 만들기 ───────── + // ───────── MKV 만들기 ([MkvBatch]) ───────── /// 목록의 MKV 만들기 (다른 작업 중이면 대기열로) /// [onExisting]: 결과 MKV 가 이미 있을 때 'overwrite' · 'skip' · 'rename' (없으면 환경 설정, "묻기" 인데 묻지 못한 곳은 'rename') Future buildAll({String? onExisting}) async { if (ffmpegVersion == null) return; - return _enqueue(tr('MKV 만들기'), () => _runBuildAll(null, onExisting)); + return _jobs.enqueue(tr('MKV 만들기'), () => _runBuild(null, onExisting)); } /// 고른 동영상만 MKV 로 (이미 만든 것도 다시 만든다 - 자막이 바뀌었을 수 있으므로) Future buildVideos(List targets, {String? onExisting}) async { if (ffmpegVersion == null || targets.isEmpty) return; final label = targets.length == 1 ? trf('MKV 만들기: {0}', [targets.first.fileName]) : trf('MKV 만들기 {0}개', [targets.length]); - return _enqueue(label, () => _runBuildAll(targets, onExisting)); + return _jobs.enqueue(label, () => _runBuild(targets, onExisting)); + } + + Future _runBuild(List? only, String? onExisting) { + final asked = onExisting ?? settings.mkv.existing; + return _mkv.run( + only?.where(videos.contains).toList() ?? videos.where((v) => v.status != JobStatus.done).toList(), + // P0: "묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름' + existing: const ['overwrite', 'skip', 'rename'].contains(asked) ? asked : 'rename', + encode: encode, + encoders: encoders, + limit: settings.mkv.maxParallelJobs, + ); } /// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데) - Future> withExistingOutput(List targets) async => [ - for (final v in targets) - if (await services.storage.exists(outputMkvPath(v.path))) v, - ]; - - /// P0: 결과 MKV 가 이미 있을 때 실제로 쓸 동작 ("묻기" 인데 묻지 못한 곳은 예전 것을 지키는 '다른 이름') - String _existingMode(String? asked) { - final m = asked ?? settings.mkv.existing; - return const ['overwrite', 'skip', 'rename'].contains(m) ? m : 'rename'; - } + Future> withExistingOutput(List targets) => _mkv.withExistingOutput(targets); /// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다) - bool buildingMkv = false; - - Future _runBuildAll([List? only, String? onExisting]) async { - final existing = _existingMode(onExisting); - _buildCancelled = false; - buildingMkv = true; - notifyListeners(); - try { - // 동시에 [maxParallelJobs] 개씩 (0 = 무제한). 작업자가 목록에서 하나씩 가져가 처리한다. - final queue = only?.where(videos.contains).toList() ?? - videos.where((v) => v.status != JobStatus.done).toList(); - if (queue.isEmpty) return; - final limit = settings.mkv.maxParallelJobs <= 0 ? queue.length : settings.mkv.maxParallelJobs; - if (queue.length > 1) _log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, limit.clamp(1, queue.length)])); - _batchQueue = queue; - _batchNext = 0; - // P0-2: 앞 실행의 ✓ · 실패가 이번 집계에 섞이지 않게 - 이번 묶음은 모두 "아직" 에서 시작 - for (final v in queue) { - v - ..status = JobStatus.ready - ..progress = 0 - ..message = null; - } - final skipped = {}; - // 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게) - final results = {}; - Future worker() async { - while (!_buildCancelled && _batchNext < queue.length) { - final v = queue[_batchNext++]; - if (await _build(v, existing)) { - results[v] = v.status; - } else { - skipped.add(v); - } - } - } - - await Future.wait([for (var i = 0; i < limit.clamp(1, queue.length); i++) worker()]); - // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨". - // 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다 - for (final v in queue) { - if (!results.containsKey(v) && !skipped.contains(v)) { - v - ..status = JobStatus.cancelled - ..message = tr('취소됨'); - results[v] = JobStatus.cancelled; - } - } - // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 - int count(JobStatus s) => results.values.where((x) => x == s).length; - _log(skipped.isEmpty - ? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', - [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]) - : trf('완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}', - [count(JobStatus.done), skipped.length, count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); - } finally { - buildingMkv = false; - _batchQueue = const []; - _batchNext = 0; - notifyListeners(); - } - } - - /// 186: 지금 MKV 만들기 묶음 (차례를 기다리는 동영상도 "대기" 로 센다) - List _batchQueue = const []; - int _batchNext = 0; + bool get buildingMkv => _mkv.building; + set buildingMkv(bool v) => _mkv.building = v; /// P0-4: 지금 MKV 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째") - int get batchTotal => buildingMkv ? _batchQueue.length : 0; - int get batchStarted => buildingMkv ? _batchNext.clamp(0, _batchQueue.length) : 0; + int get batchTotal => _mkv.total; + int get batchStarted => _mkv.started; /// 262: [v] 가 지금 만드는 묶음에 들어 있는지 - bool inCurrentBatch(VideoItem v) => buildingMkv && _batchQueue.contains(v); + bool inCurrentBatch(VideoItem v) => _mkv.contains(v); /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 - int get batchWaiting => buildingMkv ? (_batchQueue.length - _batchNext).clamp(0, _batchQueue.length) : 0; + int get batchWaiting => _mkv.waiting; /// 186: 지금 만드는 동영상 (취소 창에 보일 것) List get runningVideos => [for (final v in videos) if (v.status == JobStatus.running) v]; - /// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨" - int _cancelGen = 0; - - /// 239 · 240: 사용자가 취소하라고 고른 동영상 (만들던 것이 끝나면 "취소됨") - final _cancelledVideos = {}; - /// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서 (대기열의 다른 작업 · 동시에 도는 다른 동영상도 그대로). - /// 취소 창을 연 때의 대상을 넘긴다 - 그사이 끝나고 다음 것이 시작했으면 그 다음 것을 취소하지 않는다. /// 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것 - List cancelVideos(List targets) { - final live = [for (final v in targets) if (v.status == JobStatus.running) v]; - if (live.isNotEmpty) { - _log(trf('지금 것만 취소: {0}', [live.map((v) => v.fileName).join(', ')])); - for (final v in live) { - _cancelledVideos.add(v); - _tool.cancelTagged(v); - } - } - return [for (final v in targets) if (!live.contains(v)) v]; - } - - /// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false. - /// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고 - /// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다) - Future _build(VideoItem v, [String existing = 'rename']) async { - var out = outputMkvPath(v.path); - if (await services.storage.exists(out)) { - if (existing == 'skip') { - v - ..status = JobStatus.done - ..outputPath = out - ..message = tr('이미 있어 건너뜀'); - _log(trf('건너뜀 (이미 있음): {0}', [p.basename(out)])); - notifyListeners(); - return false; - } - if (existing == 'rename') out = await _freeName(out); - } - final part = '$out.jjpart'; - final gen = _cancelGen; - v - ..status = JobStatus.running - ..progress = 0 - ..message = null; - notifyListeners(); - _log(trf('시작: {0} → {1}{2}', [v.fileName, p.join(outputFolderName, p.basename(out)), encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}${encode.adjusts ? ' · ${encode.adjustSummary}' : ''}]' : ''])); - final copies = {}; - var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지 - try { - if (!await services.storage.exists(v.path)) throw MediaToolException(missingFileMessage); - // Android 의 FFmpeg (ffmpeg-kit) 에는 AV1 디코더가 없어 다시 인코딩할 수 없다 (원본 유지는 된다) - final vcodec = v.info?.ofType('video').firstOrNull?.codec; - if (Platform.isAndroid && encode.reencode && vcodec == 'av1') { - throw MediaToolException(tr('AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. ' '코덱을 "원본 유지" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)')); - } - await services.storage.ensureDirectory(outputDirFor(v.path)); - for (final s in v.subtitles.where((s) => s.enabled)) { - final c = await _utf8Copy(s); - if (c != null) copies[s] = c; - } - // 239: FFmpeg 가 시작하기 전에 취소됐으면 (자막 준비 중) 시작하지 않는다 - if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); - started = true; - await _tool.runFfmpeg( - buildMuxArgs(v, part, encode: encode, encoders: encoders, utf8Copies: copies), - tag: v, - duration: v.info?.duration, - onProgress: (x) { - v.progress = x; - notifyListeners(); - }, - ); - // 다 만들었을 때만 결과 이름으로 (덮어쓰기면 예전 것은 이때 바뀐다) - await services.storage.replaceFile(part, out); - v - ..status = JobStatus.done - ..outputPath = out; - _log(trf('성공: {0}', [p.basename(out)])); - } catch (e) { - final msg = e is MediaToolException - ? e.message - : e is ArgumentError - ? '${e.message}' - : '$e'; - // 187: 사용자가 취소한 것은 실패가 아니라 "취소됨" - final cancelled = gen != _cancelGen || _buildCancelled || _cancelledVideos.contains(v); - v - ..status = cancelled ? JobStatus.cancelled : JobStatus.failed - ..message = cancelled ? tr('취소됨') : msg; - _log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); - // 만들다 만 임시 파일만 지운다 - 결과 이름의 예전 MKV 는 건드리지 않는다 (P0-1) - if (started) { - try { - await services.storage.delete(part); - } catch (_) {} - } - } finally { - _cancelledVideos.remove(v); - for (final c in copies.values) { - try { - await services.storage.delete(c); - } catch (_) {} - } - } - notifyListeners(); - return true; - } - - /// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름]) - Future _freeName(String path) async { - final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); - for (var i = 2; i < 1000; i++) { - final c = p.join(dir, '$base ($i)$ext'); - if (!await services.storage.exists(c)) return c; - } - return p.join(dir, '$base (${DateTime.now().millisecondsSinceEpoch})$ext'); - } - - bool _buildCancelled = false; - - /// 목록에 넣은 뒤 파일이 옮겨지거나 지워졌을 때 - static String get missingFileMessage => tr('동영상 파일이 없습니다 (옮겨졌거나 지워졌습니다). 목록에서 빼고 다시 추가하세요.'); - - /// 지금 작업 중단 + 대기 중인 작업 모두 비우기 - /// 지금 음성인식 (취소하면 멈춘다) - SpeechRecognizer? _recognizer; + List cancelVideos(List targets) => _mkv.cancelVideos(targets); /// 45: 지금 작업만 멈추고 대기 중인 작업은 이어서 (다음 작업이 시작하면 취소 표시는 다시 꺼진다) void cancelCurrent() { if (!busy) return; - _cancelGen++; _log(trf('지금 작업 취소: {0}', [currentJob ?? ''])); - _recognizer?.cancel(); - _buildCancelled = true; - _aiCancelled = true; - _translator?.cancel(); - _tool.cancel(); + _stopNow(); } + /// 지금 작업 중단 + 대기 중인 작업 모두 비우기 void cancel() { - _cancelGen++; - _recognizer?.cancel(); - if (pendingJobs.isNotEmpty) _log(trf('대기 중인 작업 {0}개 취소', [pendingJobs.length])); - pendingJobs.clear(); - for (final (_, done) in _pendingRuns) { - done.complete(); - } - _pendingRuns.clear(); + final n = _jobs.cancelPending(); + if (n > 0) _log(trf('대기 중인 작업 {0}개 취소', [n])); for (final v in videos) { if (v.status != JobStatus.running) v.phase = null; } - _buildCancelled = true; - _aiCancelled = true; - _translator?.cancel(); + _stopNow(); + } + + void _stopNow() { + _mkv.cancelAll(); + _ai.cancel(); _tool.cancel(); } @@ -1647,26 +495,12 @@ class AppController extends ChangeNotifier { /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때) void note(String msg) => _log(msg); - /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log). 프로그램이 갑자기 꺼져도 무슨 일이 있었는지 볼 수 있게. - /// 2MB 를 넘으면 app.log.1 로 옮기고 새로 시작한다. - String? logFile; + /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog]) + String? get logFile => _logBook.file; + set logFile(String? v) => _logBook.file = v; void _log(String msg) { - final t = DateTime.now(); - String two(int n) => n.toString().padLeft(2, '0'); - logs.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg'); - if (logs.length > 500) logs.removeAt(0); - _appendLogFile('${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}:${two(t.second)} $msg'); + _logBook.add(msg); notifyListeners(); } - - void _appendLogFile(String line) { - final path = logFile; - if (path == null) return; - try { - final f = File(path); - if (f.existsSync() && f.lengthSync() > 2 * 1024 * 1024) f.renameSync('$path.1'); - f.writeAsStringSync('$line\n', mode: FileMode.append, flush: true); - } catch (_) {} - } } diff --git a/lib/app/controller/ai_options.dart b/lib/app/controller/ai_options.dart new file mode 100644 index 0000000..a417fe5 --- /dev/null +++ b/lib/app/controller/ai_options.dart @@ -0,0 +1,47 @@ +import '../../core/languages.dart'; +import '../../services/model_store.dart'; + +/// AI 자막 설정 +class AiOptions { + /// 원어 (undetermined = 자동 감지) + final Language source; + + /// 만들 언어 (기본: 한국어·영어·일본어) + final Set targets; + final ModelSpec whisper; + + const AiOptions({ + this.source = undetermined, + required this.targets, + required this.whisper, + }); + + /// 기본값: 한국어·영어·일본어 + static AiOptions defaults() => + AiOptions(targets: {for (final c in defaultTargetLanguages) languageOf(c)}, whisper: whisperModels.first); + + /// 저장한 설정 (원어 코드 · 만들 언어 코드 · 음성인식 모델 id) 에서. 모르는 것은 처음 값으로 + static AiOptions fromSaved({required String source, required List targets, required String whisper}) { + final langs = { + for (final c in targets) + if (languageOf(c) != undetermined) languageOf(c), + }; + return AiOptions( + source: languageOf(source), + targets: langs.isEmpty ? {for (final c in defaultTargetLanguages) languageOf(c)} : langs, + whisper: whisperModels.firstWhere((m) => m.id == whisper, orElse: () => whisperModels.first), + ); + } + + /// 원어와 다른 언어가 있으면 번역 모델 필요 (원어 자동이면 필요하다고 봄) + bool get needsTranslation => + source == undetermined || targets.any((t) => t.code != source.code); + + /// 259: 시작 전에 이미 번역이 필요한 것이 확실한지 - 원어를 골랐으면 다른 언어가 있을 때, + /// 자동 감지면 만들 언어가 둘 이상일 때 (하나뿐이면 감지한 원어가 그 언어일 수 있어 감지한 뒤에 정한다) + bool get surelyNeedsTranslation => + source == undetermined ? targets.length > 1 : targets.any((t) => t.code != source.code); + + AiOptions copyWith({Language? source, Set? targets, ModelSpec? whisper}) => AiOptions( + source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper); +} diff --git a/lib/app/controller/ai_subtitle_runner.dart b/lib/app/controller/ai_subtitle_runner.dart new file mode 100644 index 0000000..59e4787 --- /dev/null +++ b/lib/app/controller/ai_subtitle_runner.dart @@ -0,0 +1,302 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../core/ai_subtitle.dart'; +import '../../core/languages.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../core/srt.dart'; +import '../../core/subtitle_search.dart'; +import '../../core/text_codec.dart'; +import '../../l10n/tr.dart'; +import '../../services/ai_services.dart'; +import '../../services/media_tool.dart'; +import '../../services/model_store.dart'; +import '../../services/platform_services.dart'; +import 'ai_options.dart'; +import 'subtitle_files.dart'; +import 'video_files.dart'; + +/// AI 자막 만들기 (음성 추출 → 음성인식 → 언어별 번역) 와 있는 자막 번역. 이 기기 안에서 (Whisper · NLLB). +/// 약속: 동영상 줄 ([VideoItem]) 의 상태 · 단계 · 진행률과 자막 목록만 바꾸고 바뀔 때마다 [onChanged]. +/// 결과는 jj_mkv\파일명_AI.srt (원본) · 파일명_언어코드.srt. 모델이 없으면 받는다. [cancel] 하면 지금 단계에서 멈춘다. +/// 대기열 (하나씩) 은 부르는 쪽이 맡는다. +class AiSubtitleRunner { + final PlatformServices services; + final SubtitleFiles files; + final void Function() onChanged; + final void Function(String msg) log; + AiSubtitleRunner({required this.services, required this.files, required this.onChanged, required this.log}); + + MediaTool get _tool => services.mediaTool; + Translator? _translator; + + /// 지금 음성인식 (취소하면 멈춘다) + SpeechRecognizer? _recognizer; + + /// 지금 작업이 취소되었는지 (다음 작업을 시작하면 다시 꺼진다) + bool cancelled = false; + + /// 260: AI 자막 · 자막 번역이 모델을 쓰는 중 (그동안 환경 설정에서 그 모델을 지우지 못하게) + bool get modelsInUse => _modelUsers > 0; + int _modelUsers = 0; + + Future usingModels(Future Function() job) async { + _modelUsers++; + onChanged(); + try { + await job(); + } finally { + _modelUsers--; + onChanged(); + } + } + + void cancel() { + cancelled = true; + _recognizer?.cancel(); + _translator?.cancel(); + } + + /// [targets] 를 하나씩 (취소하면 거기서 멈춤) + Future run(List targets, AiOptions opts) async { + cancelled = false; + try { + // 1. 모델 준비 (없으면 내려받기) + // 259: 번역 모델 (NLLB 912MB) 은 번역이 확실히 필요할 때만 먼저 받는다 - 아니면 감지한 원어가 만들 언어와 다를 때 받음 + for (final m in [opts.whisper, if (opts.surelyNeedsTranslation) nllbModel]) { + await _ensureModel(m, targets); + } + for (final v in targets) { + if (cancelled) break; + await _generateOne(v, opts); + } + } catch (e) { + log(trf('AI 자막 중단: {0}', [e])); + for (final v in targets.where((v) => v.status == JobStatus.running)) { + v + ..status = JobStatus.failed + ..message = '$e' + ..phase = null; + } + } finally { + await _translator?.dispose(); + _translator = null; + onChanged(); + } + } + + /// AI 자막 모델이 없으면 내려받기 (진행은 [targets] 의 줄에) + Future _ensureModel(ModelSpec m, List targets) async { + if (await services.models.isInstalled(m)) return; + log(trf('모델 내려받는 중: {0} ({1})', [m.label, m.sizeLabel])); + await services.models.download(m, + isCancelled: () => cancelled, + onProgress: (x) { + for (final v in targets) { + v + ..phase = trf('모델 내려받는 중 {0}%', [(x * 100).round()]) + ..progress = x + ..status = JobStatus.running; + } + onChanged(); + }); + } + + Future _generateOne(VideoItem v, AiOptions opts) async { + void phase(String text, double p) { + v + ..status = JobStatus.running + ..phase = text + ..progress = p; + onChanged(); + } + + final storage = services.storage; + final tmp = p.join(await storage.tempDirectory(), 'ai_${DateTime.now().microsecondsSinceEpoch}'); + await storage.ensureDirectory(tmp); + final wav = p.join(tmp, 'audio.wav'); + log(trf('AI 자막 시작: {0}', [v.fileName])); + try { + if (!await storage.exists(v.path)) throw MediaToolException(VideoFiles.missingMessage); + // 2. 음성 추출 (전체의 5%) + if (v.info?.ofType('audio').isEmpty ?? false) { + throw MediaToolException(tr('음성 트랙이 없습니다.')); + } + phase(tr('음성 추출 중'), 0); + await _tool.runFfmpeg(buildExtractAudioArgs(v.path, wav), + duration: v.info?.duration, onProgress: (x) => phase(tr('음성 추출 중'), x * 0.05)); + if (cancelled) throw const AiCancelled(); + + // 3. 음성인식 (5% ~ 50%) + phase(tr('음성인식 중'), 0.05); + final recognizer = _recognizer = services.createRecognizer!(); + if (cancelled) throw const AiCancelled(); + final raw = await recognizer.transcribe( + wav, + modelPath: await services.models.pathOf(opts.whisper), + language: opts.source == undetermined ? 'auto' : whisperCode(opts.source), + onProgress: (x) => phase(trf('음성인식 중 {0}%', [(x * 100).round()]), 0.05 + x * 0.45), + ); + final cues = cleanRecognized(raw); + if (cues.isEmpty) throw MediaToolException(tr('인식된 말이 없습니다.')); + if (cancelled) throw const AiCancelled(); + + // 4. 원어 + final src = opts.source != undetermined ? opts.source : detectLanguage(cues.map((c) => c.text).join(' ')); + log(trf('음성인식 완료: {0}줄, 원어 {1}({2})', [cues.length, src.name, src.code])); + // 198: 자동 감지가 틀릴 수 있다 (영어를 한국어로 잡아 영어 발음을 한글로 적음) - 끝난 뒤 알리고 다른 언어로 다시 할 수 있게 + v.aiDetected = opts.source == undetermined ? src.code : null; + + // 5. 파일명_AI.srt (원본) + await storage.ensureDirectory(outputDirFor(v.path)); + await storage.writeBytes(aiSubtitlePath(v.path), encodeText(formatSrt(cues), 'UTF-8').bytes); + + // 6. 언어별 파일명_코드.srt (원어도 따로 만듦) → MKV 에 추가 + final langs = opts.targets.toList(); + for (var i = 0; i < langs.length; i++) { + final tgt = langs[i]; + final base = 0.5 + 0.5 * i / langs.length; + final span = 0.5 / langs.length; + List out; + if (tgt.code == src.code || src == undetermined) { + out = [for (final c in cues) c.copy()]; + } else { + if (_translator == null) { + // 259: 감지한 원어가 만들 언어와 달라 이제 번역이 필요 - 모델이 없으면 여기서 받는다 + await _ensureModel(nllbModel, [v]); + if (cancelled) throw const AiCancelled(); + phase(tr('번역 모델 불러오는 중'), base); + _translator = services.createTranslator!(); + await _translator!.load(await services.models.folderOf(nllbModel)); + } + phase(trf('{0} 번역 중', [tgt.name]), base); + out = await _translated(cues, _translator!, src, tgt, + (x) => phase(trf('{0} 번역 중 {1}%', [tgt.name, (x * 100).round()]), base + span * x)); + } + final path = languageSubtitlePath(v.path, tgt); + await storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes); + SubtitleFiles.addOrReplace(v, path, tgt, tgt.code == src.code ? tr('AI 인식') : tr('AI 번역')); + log(trf('저장: {0}', [p.basename(path)])); + } + v + ..status = JobStatus.ready + ..phase = null + ..progress = 0; + // 196: 경로는 기기의 구분자로 (Android 에서도 "jj_mkv\이름" 으로 보였다) + log(trf('AI 자막 완료: {0} → {1} 외 {2}개', [v.fileName, p.join(outputFolderName, '${v.baseName}_AI.srt'), langs.length])); + } on AiCancelled { + v + ..status = JobStatus.ready + ..phase = null + ..progress = 0; + cancelled = true; + log(trf('AI 자막 취소: {0}', [v.fileName])); + } catch (e) { + v + ..status = JobStatus.failed + ..phase = null + ..message = e is MediaToolException ? e.message : '$e'; + log(trf('AI 자막 실패: {0}\n{1}', [v.fileName, v.message])); + } finally { + // 작업 폴더째 지운다 (음성인식이 만드는 변환 사본 audio.wav.wav 도 함께) + try { + await Directory(tmp).delete(recursive: true); + } catch (_) { + await storage.delete(wav); + } + onChanged(); + } + } + + /// 줄마다 번역 (같은 시각, 번역한 글도 인식 결과처럼 다듬는다) + static Future> _translated( + List cues, Translator t, Language src, Language tgt, void Function(double) onProgress) async { + final texts = await translateKeepingFillers( + [for (final c in cues) c.text.replaceAll('\n', ' ')], + (rest) => t.translate(rest, source: src.nllb, target: tgt.nllb, onProgress: onProgress), + src: src.code, + tgt: tgt.code, + ); + return [ + for (var k = 0; k < cues.length; k++) + Cue(cues[k].start, cues[k].end, cleanRecognized([Cue(cues[k].start, cues[k].end, texts[k])]).firstOrNull?.text ?? texts[k]), + ]; + } + + /// 자막 [s] 를 [targets] 언어로 번역해 jj_mkv\파일명_언어코드.srt 로 저장하고 MKV 자막에 추가. + /// 원어는 자막의 언어 태그, 없으면 글자로 추정 + Future translate(VideoItem v, SubtitleEntry s, Set targets) async { + cancelled = false; + void phase(String t, double p) { + v + ..status = JobStatus.running + ..phase = t + ..progress = p; + onChanged(); + } + + Translator? translator; + try { + phase(tr('자막 불러오는 중'), 0); + final cues = await files.loadCues(v, s); + if (cues.isEmpty) throw MediaToolException(tr('자막이 비어 있습니다.')); + final src = s.language != undetermined ? s.language : detectLanguage(cues.take(200).map((c) => c.text).join(' ')); + if (src == undetermined) throw MediaToolException(tr('자막 언어를 알 수 없습니다. 자막 줄에서 언어를 먼저 고르세요.')); + final langs = targets.where((t) => t.code != src.code).toList(); + if (langs.isEmpty) throw MediaToolException(trf('원어({0})와 같은 언어로는 번역하지 않습니다.', [src.name])); + + if (!await services.models.isInstalled(nllbModel)) { + log(trf('번역 모델 내려받는 중: {0}', [nllbModel.sizeLabel])); + await services.models.download(nllbModel, + isCancelled: () => cancelled, onProgress: (x) => phase(trf('번역 모델 내려받는 중 {0}%', [(x * 100).round()]), x * 0.2)); + } + phase(tr('번역 모델 불러오는 중'), 0.2); + translator = _translator = services.createTranslator!(); + await translator.load(await services.models.folderOf(nllbModel)); + + final dir = outputDirFor(v.path); + await services.storage.ensureDirectory(dir); + final used = { + for (final f in await services.storage.listFiles(dir)) p.basename(f).toLowerCase(), + for (final e in v.subtitles) + if (e.path != null) p.basename(e.path!).toLowerCase(), + }; + for (var i = 0; i < langs.length; i++) { + if (cancelled) throw const AiCancelled(); + final tgt = langs[i]; + final base = 0.25 + 0.75 * i / langs.length; + final span = 0.75 / langs.length; + final out = await _translated(cues, translator, src, tgt, + (x) => phase(trf('{0} → {1} 번역 {2}%', [src.name, tgt.name, (x * 100).round()]), base + span * x)); + final name = nextFreeName('${v.baseName}_${tgt.code}.srt', used); + used.add(name.toLowerCase()); + final path = p.join(dir, name); + await services.storage.writeBytes(path, encodeText(formatSrt(out), 'UTF-8').bytes); + v.subtitles.add(SubtitleEntry.external( + path: path, language: tgt, charset: 'UTF-8', title: trf('{0} (AI 번역 ← {1})', [tgt.name, src.code]))); + log(trf('자막 번역 저장: {0} ({1} → {2}, {3}줄)', [name, src.name, tgt.name, cues.length])); + } + v + ..status = JobStatus.ready + ..phase = null + ..progress = 0; + } on AiCancelled { + v + ..status = JobStatus.ready + ..phase = null; + log(trf('자막 번역 취소: {0}', [v.fileName])); + } catch (e) { + v + ..status = JobStatus.failed + ..phase = null + ..message = e is MediaToolException ? e.message : '$e'; + log(trf('자막 번역 실패: {0}\n{1}', [v.fileName, v.message])); + } finally { + await translator?.dispose(); + _translator = null; + onChanged(); + } + } +} diff --git a/lib/app/controller/app_log.dart b/lib/app/controller/app_log.dart new file mode 100644 index 0000000..25f1fbc --- /dev/null +++ b/lib/app/controller/app_log.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +/// 작업 기록. 약속: [add] 는 "[시:분:초] 글" 을 [lines] 에 (최근 500줄) 남기고, [file] 이 있으면 날짜와 함께 파일에도 쓴다. +/// 파일은 2MB 를 넘으면 "이름.1" 로 옮기고 새로 시작한다 (프로그램이 갑자기 꺼져도 무슨 일이 있었는지 볼 수 있게). +/// 파일에 쓰지 못해도 기록은 남는다. +class AppLog { + final List lines = []; + + /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log) + String? file; + + static const maxLines = 500, maxFileBytes = 2 * 1024 * 1024; + + void add(String msg, {DateTime? at}) { + final t = at ?? DateTime.now(); + String two(int n) => n.toString().padLeft(2, '0'); + lines.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg'); + if (lines.length > maxLines) lines.removeAt(0); + _append('${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}:${two(t.second)} $msg'); + } + + void _append(String line) { + final path = file; + if (path == null) return; + try { + final f = File(path); + if (f.existsSync() && f.lengthSync() > maxFileBytes) f.renameSync('$path.1'); + f.writeAsStringSync('$line\n', mode: FileMode.append, flush: true); + } catch (_) {} + } +} diff --git a/lib/app/controller/app_runtime.dart b/lib/app/controller/app_runtime.dart new file mode 100644 index 0000000..96a1ca8 --- /dev/null +++ b/lib/app/controller/app_runtime.dart @@ -0,0 +1,76 @@ +import 'package:path/path.dart' as p; + +import '../../core/ftp.dart' show FtpRegistry; +import '../../core/hwp_source.dart' show removeStaleHwpWork; +import '../../core/output_paths.dart'; +import '../../core/remote_idle.dart'; +import '../../core/sftp.dart' show SftpRegistry; +import '../../core/smb.dart' show SmbRegistry; +import '../../core/languages.dart' show preferredSubtitleSetting; +import '../../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews, vaultViewRootOf; +import '../../core/webdav.dart'; +import '../../l10n/tr.dart'; +import '../../services/media_player.dart' show playerSubtitleScale; +import '../settings.dart'; + +/// 앱 전체에 걸린 실행 중 값과 켤 때의 정리. +/// 약속: [applyRuntimeSettings] 는 설정을 그 설정을 쓰는 곳 (플레이어 자막 크기 · 기본 자막 언어 · 원격 서버 목록 · +/// 원격 조각 사이 기다리기 · MKV 저장 위치) 에 건다. [cleanLeftovers] 는 지난번 강제로 꺼져 남은 임시 (문서 보기 · 보관함 보기 · +/// 보관함에서 꺼내다 남은 평문) 를 지우고 지운 것을 [log] 에 남긴다 (실패는 조용히 건너뜀). +void applyRuntimeSettings(AppSettings settings) { + playerSubtitleScale.value = settings.playback.subtitleScale; + // 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막) + preferredSubtitleSetting = settings.playback.preferredSubtitleLanguage; + // WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다) + DavRegistry.configure(settings.remote.webdavServers); + // 88: SFTP 서버 (sftp:// 경로) + SftpRegistry.configure(settings.remote.sftpServers); + // 88: Android SMB 서버 (smb:// 경로) + SmbRegistry.requireSecure = settings.remote.smbRequireSecure; + // 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기 + RemoteIdle.limit = Duration(seconds: settings.remote.idleSeconds); + SmbRegistry.configure(settings.remote.smbServers); + FtpRegistry.configure(settings.remote.ftpServers); + outputRootOverride = (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null; +} + +/// [tempDir]: 앱의 임시 폴더 +Future cleanLeftovers(Future Function() tempDir, void Function(String msg) log) async { + // 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들) + try { + final hwp = removeStaleHwpWork(await tempDir()); + if (hwp.isNotEmpty) log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); + } catch (_) {} + // 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문) + try { + final vault = removeStaleVaultViews((await vaultViewRootOf()).path); + if (vault.isNotEmpty) log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length])); + } catch (_) {} + // 관리자 지시 (10/21): 꺼내다 강제로 꺼져 받는 폴더에 남은 평문 "이름.jjvtmp" (기록에 있는 것만) + try { + final left = cleanVaultExportJournal((await vaultViewRootOf()).path); + if (left.isNotEmpty) log(trf('지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개', [left.length])); + } catch (_) {} +} + +/// 다운로드 폴더가 있는 드라이브 (예: "M:\\") - 위쪽 막대의 디스크 표시. 약속: 다운로드 폴더 설정이 바뀔 때만 다시 알아내고, +/// 알 수 없으면 빈 값 +class DownloadDisk { + final AppSettings Function() settings; + DownloadDisk(this.settings); + String? _for, _root; + + String of() { + final s = settings(); + final key = s.download.root ?? ''; + if (_root == null || _for != key) { + _for = key; + try { + _root = p.rootPrefix(p.absolute(s.resolvedDownloadRoot())); + } catch (_) { + _root = ''; + } + } + return _root!; + } +} diff --git a/lib/app/controller/encode_choice.dart b/lib/app/controller/encode_choice.dart new file mode 100644 index 0000000..03b602b --- /dev/null +++ b/lib/app/controller/encode_choice.dart @@ -0,0 +1,55 @@ +import '../../core/encode_options.dart'; +import '../../l10n/tr.dart'; + +/// 화면 크기 · 코덱 · 화질 · 보정 고르기 (모든 동영상에 적용). +/// 약속: 다시 인코딩해야 하는 것 (크기 · 보정) 을 "원본 유지" 에서 고르면 코덱을 H.264 로 저절로 바꾸고 [notice] 에 알릴 글을 둔다. +/// 크기 · 보정을 모두 원래대로 돌리면 저절로 바꾼 코덱도 되돌린다 (103). 사용자가 코덱을 직접 고르면 저절로 되돌리지 않는다. +class EncodeChoice { + EncodeSettings value = const EncodeSettings(); + + /// 코덱이 저절로 바뀌었을 때 화면에 짧게 알릴 글 (보여 주면 비움) + String? notice; + + /// 103: 크기 · 보정 때문에 "원본 유지" 에서 H.264 로 저절로 바꿨는지 + bool _auto = false; + + EncodeSettings _maybeRestoreCodec(EncodeSettings e) { + if (_auto && e.resolution == ResolutionChoice.original && !e.adjusts && e.codec == VideoCodecChoice.h264) { + _auto = false; + notice = tr('크기 · 보정을 원래대로 돌려 코덱도 "원본 유지" 로 되돌렸습니다.'); + return e.copyWith(codec: VideoCodecChoice.copy); + } + return e; + } + + void setCodec(VideoCodecChoice c) { + _auto = false; // 사용자가 직접 고름 + // 원본 유지(복사)는 크기 · 화면 비율 · 회전 · 색 보정을 할 수 없으므로 처음으로 + value = c == VideoCodecChoice.copy + ? value.resetAdjust().copyWith(codec: c, resolution: ResolutionChoice.original) + : value.copyWith(codec: c); + } + + /// 화면 비율 · 회전 · 색 보정 바꾸기 (재인코딩이 필요하면 기본 H.264 로) + void setAdjust(EncodeSettings e) { + if (e.adjusts && e.codec == VideoCodecChoice.copy) { + _auto = true; + notice = tr('보정은 다시 인코딩해야 해서 코덱을 "원본 유지" 에서 H.264 로 바꿨습니다.'); + value = e.copyWith(codec: VideoCodecChoice.h264); + } else { + value = _maybeRestoreCodec(e); + } + } + + void setResolution(ResolutionChoice r) { + // 크기를 바꾸려면 재인코딩이 필요하므로 기본 H.264 로 + final needCodec = r != ResolutionChoice.original && value.codec == VideoCodecChoice.copy; + if (needCodec) { + _auto = true; + notice = tr('크기를 바꾸면 다시 인코딩해야 해서 코덱을 "원본 유지" 에서 H.264 로 바꿨습니다.'); + } + value = _maybeRestoreCodec(value.copyWith(resolution: r, codec: needCodec ? VideoCodecChoice.h264 : null)); + } + + void setQuality(QualityChoice q) => value = value.copyWith(quality: q); +} diff --git a/lib/app/controller/job_queue.dart b/lib/app/controller/job_queue.dart new file mode 100644 index 0000000..e5754ce --- /dev/null +++ b/lib/app/controller/job_queue.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import '../../l10n/tr.dart'; + +/// 무거운 작업 (AI 자막 · 자막 번역 · MKV 만들기 - CPU · 메모리를 많이 써서 하나씩) 의 대기열. +/// 약속: [enqueue] 는 작업 중이면 대기열에 넣고 아니면 바로 실행한다. 끝나면 다음 작업을 이어서 실행. +/// 돌려주는 Future 는 그 작업이 실제로 끝날 때 (대기 중에 [cancelPending] 으로 빠지면 바로) 끝난다. +/// 상태가 바뀔 때마다 [onChanged], 대기열에 넣을 때 [log]. +class JobQueue { + final void Function() onChanged; + final void Function(String msg) log; + JobQueue({required this.onChanged, required this.log}); + + /// 작업 중인지 (화면 시험은 직접 정하기도 한다) + bool busy = false; + + /// 실행 중인 작업 이름 (없으면 null) + String? current; + + /// 대기 중인 작업 이름 (차례대로) + final List pending = []; + final List<(Future Function(), Completer)> _runs = []; + + Future enqueue(String label, Future Function() job) { + if (busy) { + final done = Completer(); + pending.add(label); + _runs.add((job, done)); + log(trf('대기열에 추가: {0} (대기 {1}개)', [label, pending.length])); + onChanged(); + return done.future; + } + return _run(label, job); + } + + Future _run(String label, Future Function() job) async { + busy = true; + current = label; + onChanged(); + try { + await job(); + } finally { + busy = false; + current = null; + onChanged(); + if (_runs.isNotEmpty) { + final (next, done) = _runs.removeAt(0); + final nextLabel = pending.removeAt(0); + unawaited(_run(nextLabel, next).then((_) => done.complete(), onError: done.completeError)); + } + } + } + + /// 대기 중인 작업을 모두 뺀다 (지금 작업은 그대로). 뺀 수 + int cancelPending() { + final n = pending.length; + pending.clear(); + for (final (_, done) in _runs) { + done.complete(); + } + _runs.clear(); + return n; + } +} diff --git a/lib/app/controller/mkv_batch.dart b/lib/app/controller/mkv_batch.dart new file mode 100644 index 0000000..e32f898 --- /dev/null +++ b/lib/app/controller/mkv_batch.dart @@ -0,0 +1,230 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../core/encode_options.dart'; +import '../../core/mkv_command_builder.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../l10n/tr.dart'; +import '../../services/media_tool.dart'; +import '../../services/storage_service.dart'; +import 'subtitle_files.dart'; +import 'video_files.dart'; + +/// MKV 만들기 묶음. 약속: [run] 은 받은 동영상들을 동시에 [limit] 개씩 (0 = 모두) 만들고 끝나면 집계를 기록한다. +/// 결과 MKV 가 이미 있으면 [run] 의 existing 에 따라 'overwrite' · 'skip' · 'rename' ("이름 (2).mkv"). +/// 임시 이름 (….mkv.jjpart) 에 만들고 다 만들었을 때만 결과 이름으로 바꿔 넣는다 (취소 · 실패면 예전 MKV 는 그대로). +/// 동영상 줄의 상태 · 진행률만 바꾸고 바뀔 때마다 [onChanged]. 대기열 (하나씩) 은 부르는 쪽이 맡는다. +class MkvBatch { + final StorageService storage; + final MediaTool tool; + final SubtitleFiles files; + final void Function() onChanged; + final void Function(String msg) log; + MkvBatch({required this.storage, required this.tool, required this.files, required this.onChanged, required this.log}); + + /// 지금 MKV 를 만드는 중인지 (32: AI 자막 · 번역만 돌 때는 인코딩 설정을 막지 않는다. 화면 시험은 직접 정하기도 한다) + bool building = false; + + /// 186: 지금 MKV 만들기 묶음 (차례를 기다리는 동영상도 "대기" 로 센다) + List _queue = const []; + int _next = 0; + + /// P0-4: 지금 묶음의 전체 수 · 시작한 수 (진행 글 "3개 중 2번째") + int get total => building ? _queue.length : 0; + int get started => building ? _next.clamp(0, _queue.length) : 0; + + /// 186: 지금 묶음에서 아직 시작하지 않은 동영상 수 + int get waiting => building ? (_queue.length - _next).clamp(0, _queue.length) : 0; + + /// 262: [v] 가 지금 만드는 묶음에 들어 있는지 + bool contains(VideoItem v) => building && _queue.contains(v); + + /// 186 · 187: 취소 요청 차례 - 만들던 중에 바뀌었으면 그 동영상은 "취소됨" + int _cancelGen = 0; + bool _cancelled = false; + + /// 239 · 240: 사용자가 취소하라고 고른 동영상 (만들던 것이 끝나면 "취소됨") + final _cancelledVideos = {}; + + /// 지금 묶음을 멈춘다 (만들던 것은 "취소됨", 시작하지 않은 것도 "취소됨") + void cancelAll() { + _cancelGen++; + _cancelled = true; + } + + /// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서. 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것 + List cancelVideos(List targets) { + final live = [for (final v in targets) if (v.status == JobStatus.running) v]; + if (live.isNotEmpty) { + log(trf('지금 것만 취소: {0}', [live.map((v) => v.fileName).join(', ')])); + for (final v in live) { + _cancelledVideos.add(v); + tool.cancelTagged(v); + } + } + return [for (final v in targets) if (!live.contains(v)) v]; + } + + Future run(List queue, + {required String existing, required EncodeSettings encode, required Set encoders, required int limit}) async { + _cancelled = false; + building = true; + onChanged(); + try { + if (queue.isEmpty) return; + final n = limit <= 0 ? queue.length : limit; + if (queue.length > 1) log(trf('MKV 만들기: {0}개, 동시에 {1}개씩', [queue.length, n.clamp(1, queue.length)])); + _queue = queue; + _next = 0; + // P0-2: 앞 실행의 ✓ · 실패가 이번 집계에 섞이지 않게 - 이번 묶음은 모두 "아직" 에서 시작 + for (final v in queue) { + v + ..status = JobStatus.ready + ..progress = 0 + ..message = null; + } + final skipped = {}; + // 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게) + final results = {}; + Future worker() async { + while (!_cancelled && _next < queue.length) { + final v = queue[_next++]; + if (await _build(v, existing, encode, encoders)) { + results[v] = v.status; + } else { + skipped.add(v); + } + } + } + + await Future.wait([for (var i = 0; i < n.clamp(1, queue.length); i++) worker()]); + // P0-2: [남은 것 모두 취소] 로 시작하지 못한 것만 "취소됨". + // 262: 상태가 "대기" 인 것으로 고르면, 끝난 뒤 다시 대기로 돌린 것까지 "취소됨" 이 되었다 + for (final v in queue) { + if (!results.containsKey(v) && !skipped.contains(v)) { + v + ..status = JobStatus.cancelled + ..message = tr('취소됨'); + results[v] = JobStatus.cancelled; + } + } + // 이번에 만든 것 기준 (목록의 다른 동영상은 세지 않음). 187: 취소는 실패로 세지 않는다 + int count(JobStatus s) => results.values.where((x) => x == s).length; + log(skipped.isEmpty + ? trf('완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}', + [count(JobStatus.done), count(JobStatus.cancelled), count(JobStatus.failed), queue.length]) + : trf('완료: 성공 {0} · 건너뜀 {1} · 취소 {2} · 실패 {3} / 전체 {4}', + [count(JobStatus.done), skipped.length, count(JobStatus.cancelled), count(JobStatus.failed), queue.length])); + } finally { + building = false; + _queue = const []; + _next = 0; + onChanged(); + } + } + + /// 만들었으면 (또는 실패 · 취소) true, 결과가 이미 있어 건너뛰었으면 false. + /// P0-1: 임시 이름 (….mkv.jjpart) 에 만들고 끝났을 때만 결과 이름으로 바꿔 넣는다 - 취소 · 실패면 임시 파일만 지우고 + /// 예전에 완성한 MKV 는 그대로 (예전에는 FFmpeg -y 가 처음부터 결과 파일을 덮어쓰고, 실패 · 취소면 그것을 지웠다) + Future _build(VideoItem v, String existing, EncodeSettings encode, Set encoders) async { + var out = outputMkvPath(v.path); + if (await storage.exists(out)) { + if (existing == 'skip') { + v + ..status = JobStatus.done + ..outputPath = out + ..message = tr('이미 있어 건너뜀'); + log(trf('건너뜀 (이미 있음): {0}', [p.basename(out)])); + onChanged(); + return false; + } + if (existing == 'rename') out = await _freeName(out); + } + final part = '$out.jjpart'; + final gen = _cancelGen; + v + ..status = JobStatus.running + ..progress = 0 + ..message = null; + onChanged(); + log(trf('시작: {0} → {1}{2}', [v.fileName, p.join(outputFolderName, p.basename(out)), encode.reencode ? ' [${encode.codec.label} · ${encode.resolution.label} · ${encode.quality.label}${encode.adjusts ? ' · ${encode.adjustSummary}' : ''}]' : ''])); + final copies = {}; + var started = false; // FFmpeg 가 출력 파일을 쓰기 시작했는지 + try { + if (!await storage.exists(v.path)) throw MediaToolException(VideoFiles.missingMessage); + // Android 의 FFmpeg (ffmpeg-kit) 에는 AV1 디코더가 없어 다시 인코딩할 수 없다 (원본 유지는 된다) + final vcodec = v.info?.ofType('video').firstOrNull?.codec; + if (Platform.isAndroid && encode.reencode && vcodec == 'av1') { + throw MediaToolException(tr('AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. ' '코덱을 "원본 유지" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)')); + } + await storage.ensureDirectory(outputDirFor(v.path)); + for (final s in v.subtitles.where((s) => s.enabled)) { + final c = await files.utf8Copy(s); + if (c != null) copies[s] = c; + } + // 239: FFmpeg 가 시작하기 전에 취소됐으면 (자막 준비 중) 시작하지 않는다 + if (_cancelledVideos.contains(v)) throw MediaToolException(tr('사용자가 취소했습니다.')); + started = true; + await tool.runFfmpeg( + buildMuxArgs(v, part, encode: encode, encoders: encoders, utf8Copies: copies), + tag: v, + duration: v.info?.duration, + onProgress: (x) { + v.progress = x; + onChanged(); + }, + ); + // 다 만들었을 때만 결과 이름으로 (덮어쓰기면 예전 것은 이때 바뀐다) + await storage.replaceFile(part, out); + v + ..status = JobStatus.done + ..outputPath = out; + log(trf('성공: {0}', [p.basename(out)])); + } catch (e) { + final msg = e is MediaToolException + ? e.message + : e is ArgumentError + ? '${e.message}' + : '$e'; + // 187: 사용자가 취소한 것은 실패가 아니라 "취소됨" + final cancelled = gen != _cancelGen || _cancelled || _cancelledVideos.contains(v); + v + ..status = cancelled ? JobStatus.cancelled : JobStatus.failed + ..message = cancelled ? tr('취소됨') : msg; + log(cancelled ? trf('취소됨: {0}', [v.fileName]) : trf('실패: {0}\n{1}', [v.fileName, msg])); + // 만들다 만 임시 파일만 지운다 - 결과 이름의 예전 MKV 는 건드리지 않는다 (P0-1) + if (started) { + try { + await storage.delete(part); + } catch (_) {} + } + } finally { + _cancelledVideos.remove(v); + for (final c in copies.values) { + try { + await storage.delete(c); + } catch (_) {} + } + } + onChanged(); + return true; + } + + /// P0: 결과 MKV 가 이미 있는 동영상 (만들기 전에 덮어쓸지 묻는 데) + Future> withExistingOutput(List targets) async => [ + for (final v in targets) + if (await storage.exists(outputMkvPath(v.path))) v, + ]; + + /// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름]) + Future _freeName(String path) async { + final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); + for (var i = 2; i < 1000; i++) { + final c = p.join(dir, '$base ($i)$ext'); + if (!await storage.exists(c)) return c; + } + return p.join(dir, '$base (${DateTime.now().millisecondsSinceEpoch})$ext'); + } +} diff --git a/lib/app/controller/playback_prep.dart b/lib/app/controller/playback_prep.dart new file mode 100644 index 0000000..8875c81 --- /dev/null +++ b/lib/app/controller/playback_prep.dart @@ -0,0 +1,49 @@ +import 'package:path/path.dart' as p; + +import '../../core/playlist.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../../services/app_shell.dart'; +import '../../services/storage_service.dart'; +import '../settings.dart'; + +/// 재생 준비. 약속: [prepare] 는 동영상 · 음악만 골라, 확장자에 외부 프로그램이 지정되어 있으면 그것으로 열고 null, +/// 아니면 내장 플레이어용 (목록, 시작 위치) 을 준다. 하나면 같은 폴더로 재생 목록 ([PlaybackSettings.playlistMode]). +class PlaybackPrep { + final StorageService storage; + final AppShell shell; + final void Function(String msg) log; + PlaybackPrep({required this.storage, required this.shell, required this.log}); + + /// [keepOrder]: 여러 개를 줄 때 이름순으로 다시 정렬하지 않고 준 순서대로 재생 + /// [internal]: 외부 프로그램 설정과 상관없이 내장 플레이어로 (파일 탐색기의 "내장 플레이어로 재생") + /// [external]: 외부 프로그램으로 열 때 (화면이 WebDAV 로그인 안내 등을 한다 - 55). 없으면 바로 연다 + Future<(List, int)?> prepare(List files, PlaybackSettings settings, + {bool keepOrder = false, + bool internal = false, + Future Function(String program, List videos)? external}) async { + // 29: 음악도 내장 플레이어로 (탐색기 메뉴의 "내장 플레이어로 재생") + final videos = files.where((f) => isVideoFile(f) || isAudioFile(f)).toList(); + if (videos.isEmpty) return null; + final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); + final program = settings.externalPlayers[ext]; + if (!internal && program != null && program.isNotEmpty) { + // WebDAV 는 스트리밍 주소로 넘긴다 (아이디 · 비밀번호는 넣지 않음 - 55) + if (external != null) { + await external(program, videos); + } else { + await shell.openExternal(program, videos.map(vPlayable).toList()); + } + log(trf('외부 프로그램으로 재생: {0} ← {1}개', [program == 'system' ? tr('기본 연결 프로그램') : p.basename(program), videos.length])); + return null; + } + if (videos.length > 1) { + if (!keepOrder) videos.sort((a, b) => naturalCompare(p.basename(a), p.basename(b))); + return (videos, 0); + } + final siblings = isDav(videos.first) + ? [for (final e in await vList(vDirname(videos.first))) if (!e.isDir) e.path] + : await storage.listFiles(p.dirname(videos.first)); + return buildPlaylist(videos.first, siblings, settings.playlistMode); + } +} diff --git a/lib/app/controller/subtitle_download.dart b/lib/app/controller/subtitle_download.dart new file mode 100644 index 0000000..d195431 --- /dev/null +++ b/lib/app/controller/subtitle_download.dart @@ -0,0 +1,76 @@ +import 'package:path/path.dart' as p; + +import '../../core/charset_detector.dart'; +import '../../core/languages.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../core/srt.dart'; +import '../../core/subtitle_search.dart'; +import '../../core/text_codec.dart'; +import '../../l10n/tr.dart'; +import '../../services/storage_service.dart'; +import '../../services/subtitle_provider.dart'; + +/// 인터넷 자막 (5단계). 약속: [queryFor] 는 파일명으로 검색 조건을 추정하고 영상 해시를 붙인다 (못 읽으면 해시 없이). +/// [download] 는 고른 자막을 받아 jj_mkv\파일명_언어코드.srt (UTF-8, 이름이 겹치면 다른 이름) 로 저장하고 [v] 의 자막에 넣는다. +/// 하나 받을 때마다 [onAdded] (받은 자막 · 그 언어). 받기 횟수가 다 되면 멈춘다. 받은 수를 준다. +class SubtitleDownload { + final StorageService storage; + final void Function(String msg) log; + SubtitleDownload(this.storage, {required this.log}); + + Future queryFor(VideoItem v, List langs) async { + final q = guessQuery(v.path, languages: langs); + String? hash; + try { + final size = await storage.fileSize(v.path); + const chunk = 65536; + if (size >= chunk) { + hash = openSubtitlesHash(size, await storage.readRange(v.path, 0, chunk), await storage.readRange(v.path, size - chunk, chunk)); + } + } catch (_) {} + return SubtitleQuery( + title: q.title, year: q.year, season: q.season, episode: q.episode, movieHash: hash, languages: langs); + } + + Future> search(SubtitleProvider provider, SubtitleQuery q) async { + final r = await provider.search(q); + log(trf('자막 검색 ({0}): "{1}"{2} → {3}개', [provider.name, q.title, q.season != null ? ' S${q.season}E${q.episode}' : '', r.length])); + return r; + } + + Future download(VideoItem v, List picks, SubtitleProvider provider, + {required void Function(SubtitleEntry entry, SubtitleSearchResult from) onAdded}) async { + final dir = outputDirFor(v.path); + await storage.ensureDirectory(dir); + final used = { + for (final f in await storage.listFiles(dir)) p.basename(f).toLowerCase(), + for (final s in v.subtitles) + if (s.path != null) p.basename(s.path!).toLowerCase(), + }; + var ok = 0; + for (final r in picks) { + try { + final bytes = await provider.download(r); + final text = decodeText(bytes, detectCharset(bytes)); + final cues = parseSrt(text); + if (cues.isEmpty) throw SubtitleProviderException(tr('SRT 형식이 아니거나 비어 있습니다.')); + final name = nextFreeName('${v.baseName}_${r.language.code}.srt', used); + used.add(name.toLowerCase()); + final path = p.join(dir, name); + await storage.writeBytes(path, encodeText(formatSrt(cues), 'UTF-8').bytes); + final entry = SubtitleEntry.external( + path: path, language: r.language, charset: 'UTF-8', title: '${r.language.name} (${provider.name})'); + v.subtitles.add(entry); + ok++; + onAdded(entry, r); + log(trf('자막 받음: {0} ← {1}', [name, r.release])); + } catch (e) { + log(trf('자막 받기 실패: {0}\n{1}', [r.release, e])); + if (e is SubtitleProviderException && e.quotaExceeded) break; + } + } + if (v.status == JobStatus.done && ok > 0) v.status = JobStatus.ready; + return ok; + } +} diff --git a/lib/app/controller/subtitle_files.dart b/lib/app/controller/subtitle_files.dart new file mode 100644 index 0000000..a24ce9b --- /dev/null +++ b/lib/app/controller/subtitle_files.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../../core/charset_detector.dart'; +import '../../core/languages.dart'; +import '../../core/mkv_command_builder.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../core/srt.dart'; +import '../../core/subtitle_detector.dart'; +import '../../core/text_codec.dart'; +import '../../core/vfs.dart'; +import '../../services/media_tool.dart'; +import '../../services/storage_service.dart'; + +/// 자막 파일 다루기 (읽기 · UTF-8 사본 · 편집본 이름 · 재생용 사본 · 재생 중 고를 외부 자막). +/// 약속: 동영상 목록 · 화면 상태를 바꾸지 않는다 - 파일과 [SubtitleEntry] 만 만들어 준다. 임시 파일은 [storage] 의 임시 폴더에. +class SubtitleFiles { + final StorageService storage; + final MediaTool tool; + SubtitleFiles(this.storage, this.tool); + + /// 외부 자막 항목 (문자셋은 앞 64KB 로 알아낸다, 못 읽으면 UTF-8) + Future external(String path, Language lang) async { + String charset = 'UTF-8'; + try { + final head = await storage.readHead(path, 64 * 1024); + charset = detectCharset(Uint8List.fromList(head)); + } catch (_) {} + return SubtitleEntry.external(path: path, language: lang, charset: charset); + } + + /// 앱이 만든 UTF-8 자막 [path] 를 [v] 의 자막 목록에 넣는다 (같은 파일이 있으면 바꿔 넣음) + static void addOrReplace(VideoItem v, String path, Language lang, String title) { + final entry = SubtitleEntry.external(path: path, language: lang, charset: 'UTF-8', title: '${lang.name} ($title)'); + final i = v.subtitles.indexWhere((s) => s.path != null && p.equals(s.path!, path)); + if (i >= 0) { + v.subtitles[i] = entry; + } else { + v.subtitles.add(entry); + } + } + + /// 자막을 큐 목록으로 불러온다. + /// - 외부 SRT: 지정 문자셋으로 직접 읽음 + /// - 외부 SMI/ASS/VTT, 내장 트랙: FFmpeg 로 UTF-8 SRT 변환 후 읽음 + Future> loadCues(VideoItem v, SubtitleEntry s) async { + if (s.kind == SubtitleKind.external && s.codec == 'srt') { + final bytes = await storage.readBytes(s.path!); + return parseSrt(decodeText(bytes, s.charset ?? 'UTF-8')); + } + final tmp = p.join(await storage.tempDirectory(), 'edit_${DateTime.now().microsecondsSinceEpoch}.srt'); + final copy = await utf8Copy(s); + try { + await tool.runFfmpeg(s.kind == SubtitleKind.external + ? buildToSrtArgs(input: copy ?? s.path!, output: tmp, charset: copy == null ? s.charset : null) + : buildToSrtArgs(input: v.path, output: tmp, streamIndex: s.streamIndex)); + return parseSrt(decodeText(await storage.readBytes(tmp), 'UTF-8')); + } finally { + await storage.delete(tmp); + if (copy != null) await storage.delete(copy); + } + } + + /// UTF-8 이 아닌 외부 자막 (CP949 SMI 등) 의 UTF-8 임시 사본. 필요 없으면 null. + /// Android 의 FFmpeg (ffmpeg-kit) 에는 문자셋 변환 (iconv) 이 없어 -sub_charenc 를 쓸 수 없으므로 + /// 앱이 바꿔서 넘긴다 (Windows 도 같은 방식으로). + Future utf8Copy(SubtitleEntry s) async { + final cs = s.charset; + if (s.kind != SubtitleKind.external || s.path == null || cs == null || cs.startsWith('UTF-')) return null; + final text = decodeText(await storage.readBytes(s.path!), cs); + final copy = p.join(await storage.tempDirectory(), 'utf8_${DateTime.now().microsecondsSinceEpoch}_${p.basename(s.path!)}'); + await storage.writeBytes(copy, utf8.encode(text)); + return copy; + } + + /// 편집한 자막을 [editedPath] 에 [charset] 으로 저장하고 [v] 의 목록을 바꾼다 (MKV 에 반영되게). + /// - 내장 자막: 원래 트랙은 '삭제' 표시, 편집본을 외부 자막으로 추가 + /// - 외부 자막: 같은 자리의 항목을 편집본으로 교체 (원본 파일은 그대로 둠) + /// 돌려주는 값: (저장 경로, 표현할 수 없어 '?' 로 바뀐 글자 수) + Future<(String, int)> saveEdited(VideoItem v, SubtitleEntry s, List cues, String charset) async { + final enc = encodeText(formatSrt(cues), charset); + final target = editedPath(v, s); + await storage.writeBytes(target, enc.bytes); + final edited = SubtitleEntry.external( + path: target, + language: s.language, + charset: charset == 'UTF-8-BOM' ? 'UTF-8' : charset, + title: s.title, + ); + final i = v.subtitles.indexOf(s); + if (s.kind == SubtitleKind.embedded) { + s.enabled = false; + v.subtitles.insert(i + 1, edited); + } else { + v.subtitles[i] = edited; + } + if (v.status == JobStatus.done) v.status = JobStatus.ready; + return (target, enc.lostChars); + } + + /// 편집본을 저장할 곳: jj_mkv\파일명_언어코드.srt (겹치면 _2 …). 이미 편집본 (jj_mkv 안의 .srt) 이면 같은 파일에 덮어쓴다 + static String editedPath(VideoItem v, SubtitleEntry s) { + if (s.kind == SubtitleKind.external && + p.equals(p.dirname(s.path!), outputDirFor(v.path)) && + p.extension(s.path!).toLowerCase() == '.srt') { + return s.path!; + } + final base = languageSubtitlePath(v.path, s.language); + final used = v.subtitles.where((e) => e != s && e.path != null).map((e) => p.normalize(e.path!).toLowerCase()).toSet(); + var candidate = base; + for (var n = 2; used.contains(p.normalize(candidate).toLowerCase()); n++) { + candidate = '${p.withoutExtension(base)}_$n.srt'; + } + return candidate; + } + + /// 플레이어에 넘길 자막 파일 (Android). Android 의 mpv 는 문자셋 변환 (iconv) 이 없고 SAMI (SMI) 도 열지 못하므로 + /// UTF-8 이 아닌 자막은 UTF-8 사본으로, SMI 는 SRT 로 바꿔 임시 폴더에 만든다. 그 밖에는 그대로. + Future playable(String path, {bool android = false}) async { + if (!android) return path; + try { + final ext = p.extension(path).toLowerCase(); + final isSami = ext == '.smi' || ext == '.sami'; + final cs = detectCharset(Uint8List.fromList(await storage.readHead(path, 64 * 1024))); + if (cs == 'UTF-8' && !isSami) return path; + final tmp = await storage.tempDirectory(); + final key = path.hashCode.toUnsigned(32); + var src = path; + if (cs != 'UTF-8') { + src = p.join(tmp, 'play_${key}_${p.basename(path)}'); + await storage.writeBytes(src, utf8.encode(decodeText(await storage.readBytes(path), cs))); + } + if (!isSami) return src; + final srt = p.join(tmp, 'play_$key.srt'); + await tool.runFfmpeg(buildToSrtArgs(input: src, output: srt)); + return srt; + } catch (_) { + return path; + } + } + + /// 재생 중 고를 수 있는 외부 자막: 같은 폴더의 자막 + jj_mkv 안의 파일명_*.srt + Future> externalFor(String video) async { + final out = []; + if (isDav(video)) { + // WebDAV: 같은 폴더의 자막만 (작으므로 임시 폴더로 받아서 넘긴다) + try { + final files = [for (final e in await vList(vDirname(video))) if (!e.isDir) e.path]; + final temp = await storage.tempDirectory(); + for (final d in findSiblingSubtitles(video, files)) { + out.add(await vLocalCopy(d.path, temp)); + } + } catch (_) {} + return out; + } + out.addAll(findSiblingSubtitles(video, await storage.listFiles(p.dirname(video))).map((d) => d.path)); + final base = p.basenameWithoutExtension(video).toLowerCase(); + for (final f in await storage.listFiles(outputDirFor(video))) { + final n = p.basename(f).toLowerCase(); + if (n.startsWith('${base}_') && subtitleExtensions.contains(p.extension(n).replaceFirst('.', ''))) { + out.add(f); + } + } + return out; + } +} diff --git a/lib/app/controller/video_files.dart b/lib/app/controller/video_files.dart new file mode 100644 index 0000000..a18c142 --- /dev/null +++ b/lib/app/controller/video_files.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../core/output_paths.dart'; +import '../../l10n/tr.dart'; + +/// 동영상 파일 옮기기. 약속: [moveFileInto] 는 파일을 폴더 안으로 옮기고 (같은 이름이 있으면 "이름 (2).mp4") 옮긴 경로를 준다. +/// 다른 드라이브 · SD 카드면 복사 후 지우며, 원본을 지우지 못하면 사본을 지우고 오류를 낸다 (두 벌이 남지 않게). +/// [moveResults] 는 만든 결과물 (jj_mkv 의 이름.mkv · 이름_AI.srt · 이름_ko.srt …) 을 옮긴 동영상의 jj_mkv 로 옮기고 그 수를 준다. +class VideoFiles { + /// 목록에 넣은 뒤 파일이 옮겨지거나 지워졌을 때 + static String get missingMessage => tr('동영상 파일이 없습니다 (옮겨졌거나 지워졌습니다). 목록에서 빼고 다시 추가하세요.'); + + static Future moveFileInto(String from, String dir) async { + final stem = p.basenameWithoutExtension(from), ext = p.extension(from); + var to = p.join(dir, p.basename(from)); + for (var n = 2; File(to).existsSync(); n++) { + to = p.join(dir, '$stem ($n)$ext'); + } + try { + await File(from).rename(to); + } on FileSystemException { + try { + await File(from).copy(to); + await File(from).delete(); + } catch (_) { + // 원본을 지우지 못하면 (열려 있음 등) 옮긴 것이 아니다 - 방금 만든 사본을 지워 두 벌이 남지 않게 (원본은 그대로) + try { + if (File(from).existsSync()) await File(to).delete(); + } catch (_) {} + rethrow; + } + } + return to; + } + + /// [log]: 못 옮긴 결과물 (나머지는 계속 옮긴다) + static Future moveResults(String oldVideo, String newVideo, {required void Function(String msg) log}) async { + final from = outputDirFor(oldVideo), to = outputDirFor(newVideo); + if (p.equals(from, to) || !Directory(from).existsSync()) return 0; + final stem = p.basenameWithoutExtension(oldVideo); + final mine = RegExp('^${RegExp.escape(stem)}(\\.mkv|_[A-Za-z-]+\\.(srt|ass|ssa|smi|vtt|sub))\$', caseSensitive: false); + var n = 0; + for (final f in Directory(from).listSync().whereType()) { + if (!mine.hasMatch(p.basename(f.path))) continue; + try { + await Directory(to).create(recursive: true); + await moveFileInto(f.path, to); + n++; + } catch (e) { + log(trf('결과물 이동 실패: {0} ({1})', [p.basename(f.path), e])); + } + } + return n; + } +} diff --git a/lib/app/controller/video_list.dart b/lib/app/controller/video_list.dart new file mode 100644 index 0000000..168ae41 --- /dev/null +++ b/lib/app/controller/video_list.dart @@ -0,0 +1,225 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../core/playlist.dart'; +import '../../l10n/tr.dart'; +import 'video_files.dart'; + +/// 편집 목록 (동영상 · 보는 것 · 체크한 것). 약속: 작업 중인 동영상은 빼거나 옮기지 않는다. 목록이 바뀌면 [onChanged], +/// 저장할 만큼 바뀌면 [onSave] (목록 파일 - [VideoListShare]). 분석 ([load]) 과 기록 ([log]) 은 부르는 쪽이 준다. +class VideoList { + final void Function() onChanged; + final void Function() onSave; + final Future Function(VideoItem v) load; + final void Function(String msg) log; + VideoList({required this.onChanged, required this.onSave, required this.load, required this.log}); + + final List items = []; + VideoItem? selected; + + /// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상) + final Set checked = {}; + + /// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로. + String? sortedBy; + bool sortAscending = true; + + /// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다 + Future add(List paths, {bool allowOutputFolder = false}) async { + for (final path in paths) { + if (items.any((v) => p.equals(v.path, path))) continue; + // jj_mkv 출력 폴더 안의 파일은 제외 (폴더째 넣을 때 만든 결과물이 다시 들어가지 않게) + if (!allowOutputFolder && p.basename(p.dirname(path)) == outputFolderName) { + log(trf('건너뜀 (출력 폴더의 파일): {0}', [p.basename(path)])); + continue; + } + final v = VideoItem(path); + items.add(v); + selected ??= v; + onChanged(); + await load(v); + } + onSave(); + } + + /// 116: 분석에 실패했던 동영상을 다시 분석한다 (권한을 허용하고 돌아왔을 때). 다시 분석한 수 + Future reanalyzeFailed() async { + var n = 0; + for (final v in [...items]) { + if (v.info != null || v.message == null || v.status == JobStatus.running) continue; + if (!items.contains(v)) continue; + v.message = null; + v.subtitles.removeWhere((s) => s.kind == SubtitleKind.embedded); + onChanged(); + await load(v); + n++; + } + if (n > 0) log(trf('분석에 실패했던 동영상 {0}개를 다시 분석했습니다', [n])); + return n; + } + + /// 다른 창이 바꾼 목록 [want] 에 맞춘다 (작업 중인 동영상은 지우지 않는다, 없는 파일은 넣지 않는다) + Future follow(List want) async { + bool inWant(VideoItem v) => want.any((w) => p.equals(w, v.path)); + items.removeWhere((v) => !inWant(v) && v.status != JobStatus.running); + if (selected != null && !items.contains(selected)) selected = items.firstOrNull; + onChanged(); + await add([for (final w in want) if (File(w).existsSync()) w], allowOutputFolder: true); + } + + /// 파일 이름 ('name') 또는 파일 날짜 ('date') 순으로 정렬 + void sort(String by) { + sortAscending = sortedBy == by ? !sortAscending : true; + sortedBy = by; + final dates = {}; + if (by == 'date') { + for (final v in items) { + try { + dates[v] = File(v.path).lastModifiedSync(); + } catch (_) { + dates[v] = DateTime.fromMillisecondsSinceEpoch(0); + } + } + } + items.sort((a, b) { + var d = by == 'date' ? dates[a]!.compareTo(dates[b]!) : 0; + if (d == 0) d = naturalCompare(a.fileName.toLowerCase(), b.fileName.toLowerCase()); + return sortAscending ? d : -d; + }); + onSave(); + onChanged(); + } + + void select(VideoItem v) { + selected = v; + onChanged(); + } + + void toggleChecked(VideoItem v) { + if (!checked.remove(v)) checked.add(v); + onChanged(); + } + + /// 모두 체크되어 있으면 모두 해제, 아니면 모두 체크 + void toggleAllChecked() { + if (checked.length == items.length) { + checked.clear(); + } else { + checked + ..clear() + ..addAll(items); + } + onChanged(); + } + + /// 일괄 작업 대상: 체크한 동영상 (목록 순서대로). 체크한 것이 없으면 지금 보고 있는 한 개. + List get batchTargets { + checked.retainAll(items); + if (checked.isNotEmpty) return items.where(checked.contains).toList(); + return [?selected]; + } + + /// 체크한 동영상을 목록에서 제거 (작업 중인 것은 남긴다) + void removeChecked() { + final gone = checked.where((v) => v.status != JobStatus.running).toList(); + items.removeWhere(gone.contains); + checked.removeAll(gone); + if (selected != null && !items.contains(selected)) selected = items.firstOrNull; + _changed(); + } + + void remove(VideoItem v) { + if (v.status == JobStatus.running) return; + items.remove(v); + checked.remove(v); + if (selected == v) selected = items.isEmpty ? null : items.first; + _changed(); + } + + void clear() { + items.clear(); + checked.clear(); + selected = null; + _changed(); + } + + void _changed() { + onSave(); + onChanged(); + } + + /// 동영상을 [dest] 로 옮긴다. 옆에 있는 같은 이름의 자막 파일 (a.srt · a.ko.smi …) 과 + /// 만든 결과물 (jj_mkv 의 a.mkv · a_AI.srt · a_ko.srt …) 도 함께 (결과물은 이동 폴더의 jj_mkv 로). + /// 옮긴 동영상은 목록에서 뺀다. 반환: (옮긴 수, 못 옮긴 이유들) + Future<(int, List)> move(List targets, String dest) async { + if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]); + var moved = 0; + final errors = []; + try { + await Directory(dest).create(recursive: true); + } catch (e) { + return (0, [trf('이동할 폴더를 만들 수 없습니다: {0} ({1})', [dest, e])]); + } + for (final v in List.of(targets)) { + if (v.status == JobStatus.running) { + errors.add(trf('{0}: 작업 중이라 옮기지 않았습니다', [v.fileName])); + continue; + } + if (p.equals(v.directory, dest)) { + errors.add(trf('{0}: 이미 이동 폴더에 있습니다', [v.fileName])); + continue; + } + // 옮기기 시작하면 바로 목록에서 뺀다 (다른 드라이브 · NAS 로는 복사라 큰 동영상은 몇 초 넘게 남아 보였다). + // 옮기지 못하면 원래 자리 · 표시 · 선택으로 되돌린다. + final at = items.indexOf(v); + final wasChecked = checked.contains(v); + final wasSelected = selected == v; + items.remove(v); + checked.remove(v); + if (wasSelected) selected = items.elementAtOrNull(at) ?? items.lastOrNull; + onChanged(); + try { + final side = { + for (final s in v.subtitles) + if (s.path != null && + p.equals(p.dirname(s.path!), v.directory) && + p.basename(s.path!).startsWith('${v.baseName}.') && + File(s.path!).existsSync()) + s.path!, + }; + final to = await VideoFiles.moveFileInto(v.path, dest); + for (final s in side) { + try { + await VideoFiles.moveFileInto(s, dest); + } catch (e) { + log(trf('자막 이동 실패: {0} ({1})', [p.basename(s), e])); + } + } + final results = await VideoFiles.moveResults(v.path, to, log: log); + log(trf('이동: {0} → {1}{2}', [ + v.fileName, + to, + [ + if (side.isNotEmpty) trf(' (자막 {0}개 함께)', [side.length]), + if (results > 0) trf(' (결과물 {0}개 함께)', [results]), + ].join(), + ])); + moved++; + } catch (e) { + errors.add('${v.fileName}: $e'); + if (File(v.path).existsSync() && !items.contains(v)) { + items.insert(at.clamp(0, items.length), v); + if (wasChecked) checked.add(v); + if (wasSelected) selected = v; + onChanged(); + } + } + } + if (selected != null && !items.contains(selected)) selected = items.firstOrNull; + _changed(); + return (moved, errors); + } +} diff --git a/lib/app/controller/video_list_share.dart b/lib/app/controller/video_list_share.dart new file mode 100644 index 0000000..8fcf4de --- /dev/null +++ b/lib/app/controller/video_list_share.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// 동영상 목록 저장 · 창끼리 공유. 목록 (경로) 을 파일 하나에 적어 두고, 다시 켜면 그대로 불러오며, 창이 여러 개면 +/// 1초마다 파일을 보고 다른 창에서 넣거나 지운 것을 따라 한다. +/// 약속: [save] 는 [paths] 가 바뀌었을 때만 임시 파일 → 이름 바꾸기로 쓴다 (다른 창이 반쯤 쓴 파일을 보지 않게). +/// [sync] 는 파일이 바뀌었으면 원하는 목록을 [apply] 에 넘긴다 (그동안 [save] 는 쉬고, 끝나면 한 번 다시 적는다). +class VideoListShare { + /// 지금 목록의 경로들 + final List Function() paths; + + /// 파일에 적힌 목록에 맞춘다 (다른 창이 바꾼 것) + final Future Function(List want) apply; + VideoListShare({required this.paths, required this.apply}); + + String? _file; + String _saved = '[]'; + Timer? _timer; + bool _syncing = false; + + /// 저장 · 공유 시작 ([file] 을 먼저 불러온다) + Future start(String file) async { + _file = file; + await sync(); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) => sync()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + void save() { + final file = _file; + if (file == null || _syncing) return; + final text = jsonEncode(paths()); + if (text == _saved) return; + _saved = text; + try { + final tmp = File('$file.$pid.tmp')..writeAsStringSync(text, flush: true); + tmp.renameSync(file); + } catch (_) {} + } + + Future sync() async { + final file = _file; + if (file == null || _syncing) return; + String text; + try { + final f = File(file); + if (!f.existsSync()) return; + text = f.readAsStringSync(); + } catch (_) { + return; + } + if (text == _saved) return; + List want; + try { + want = (jsonDecode(text) as List).cast(); + } catch (_) { + return; + } + _syncing = true; + try { + _saved = text; + await apply(want); + } finally { + _syncing = false; + save(); // 없는 파일을 뺐거나 맞추는 동안 이 창에서 바꾼 것이 있으면 다시 적는다 + } + } +} diff --git a/lib/app/controller/video_probe.dart b/lib/app/controller/video_probe.dart new file mode 100644 index 0000000..d1f085f --- /dev/null +++ b/lib/app/controller/video_probe.dart @@ -0,0 +1,84 @@ +import 'package:path/path.dart' as p; + +import '../../core/languages.dart'; +import '../../core/models.dart'; +import '../../core/output_paths.dart'; +import '../../core/subtitle_detector.dart'; +import '../../l10n/tr.dart'; +import '../../services/media_tool.dart'; +import '../../services/storage_service.dart'; +import 'subtitle_files.dart'; + +/// 동영상 분석과 자막 찾기. 약속: [load] 는 FFmpeg 로 분석해 [VideoItem.info] 와 내장 자막을 채우고, +/// 같은 폴더의 자막 (기능 4) 과 전에 만든 자막 (jj_mkv 의 파일명_ko.srt 등) 을 붙인다 (이미 있는 것은 그대로). +/// 분석에 실패하면 [VideoItem.message] 에 이유 (116: 권한이 없어 못 읽은 것이면 알아볼 수 있는 안내) 를 둔다. +class VideoProbe { + final StorageService storage; + final MediaTool tool; + final SubtitleFiles files; + final void Function(String msg) log; + + /// 116 (Android): 저장소 전체 접근 권한이 있는지 (null = 묻지 않음 - Windows) + final Future Function()? Function() fileAccess; + VideoProbe({required this.storage, required this.tool, required this.files, required this.log, required this.fileAccess}); + + static String get accessNeededMessage => tr('"모든 파일에 대한 접근" 권한이 없어 읽을 수 없습니다. 허용하면 저절로 다시 분석합니다.'); + + static bool isPermissionDenied(String message) { + final m = message.toLowerCase(); + return m.contains('permission denied') || m.contains('eacces') || m.contains('operation not permitted'); + } + + Future load(VideoItem v) async { + try { + v.info = await tool.probe(v.path); + // 내장 자막 (mkv 등) + for (final s in v.info!.ofType('subtitle')) { + v.subtitles.add(SubtitleEntry.embedded( + streamIndex: s.index, + codec: s.codec, + language: languageOf(s.language), + title: s.title, + )); + } + } on MediaToolException catch (e) { + // 116: 권한이 없어 못 읽은 것이면 빨간 Permission denied 대신 알아볼 수 있게 (허용하면 다시 분석) + final access = fileAccess(); + final denied = isPermissionDenied(e.message) && access != null && !await access(); + v.message = denied ? accessNeededMessage : e.message; + log(trf('분석 실패: {0} - {1}', [v.fileName, e.message])); + } + + // 같은 폴더의 자막 자동 추가 (기능 4) - 다시 분석할 때 이미 있는 것은 그대로 + List folder = const []; + try { + folder = await storage.listFiles(v.directory); + } catch (_) {} + final siblings = findSiblingSubtitles(v.path, folder); + for (final d in siblings) { + if (_has(v, d.path)) continue; + v.subtitles.add(await files.external(d.path, d.language)); + } + // 전에 만든 자막 (jj_mkv 의 파일명_ko.srt 등: AI 자막 · 번역 · 인터넷 자막) 도 다시 연결 + // (앱을 다시 켜거나 목록에 다시 넣어도 MKV 에 빠지지 않게). 음성인식 원본 파일명_AI.srt 는 언어별 파일과 같아서 뺀다. + var made = 0; + final out = outputDirFor(v.path); + if (!p.equals(out, v.directory)) { + List found = const []; + try { + found = await storage.listFiles(out); + } catch (_) {} + for (final d in findSiblingSubtitles(v.path, found)) { + if (d.isAi || d.language == undetermined) continue; + if (_has(v, d.path)) continue; + v.subtitles.add(await files.external(d.path, d.language)); + made++; + } + } + final embeddedCount = v.subtitles.where((s) => s.kind == SubtitleKind.embedded).length; + log(trf('추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)', [v.fileName, embeddedCount, siblings.length]) + + (made > 0 ? trf(' · 전에 만든 자막 {0}개 (jj_mkv)', [made]) : '')); + } + + static bool _has(VideoItem v, String path) => v.subtitles.any((s) => s.path != null && p.equals(s.path!, path)); +} diff --git a/test/controller_modules_contract_test.dart b/test/controller_modules_contract_test.dart new file mode 100644 index 0000000..1e4f173 --- /dev/null +++ b/test/controller_modules_contract_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/controller/app_log.dart'; +import 'package:jj_mkvmaker/app/controller/encode_choice.dart'; +import 'package:jj_mkvmaker/app/controller/job_queue.dart'; +import 'package:jj_mkvmaker/app/controller/video_files.dart'; +import 'package:jj_mkvmaker/app/controller/video_list.dart'; +import 'package:jj_mkvmaker/app/controller/video_list_share.dart'; +import 'package:jj_mkvmaker/core/encode_options.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:path/path.dart' as p; + +/// app_controller 를 나눈 모듈의 약속 (실제 파일 · 실제 Future 로) +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ctl_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('약속 JobQueue: 하나씩 차례로, 대기 중인 것은 빼면 바로 끝나고 지금 것은 그대로', () async { + final logs = []; + final q = JobQueue(onChanged: () {}, log: logs.add); + final order = []; + final gate = Completer(); + final a = q.enqueue('A', () async { + order.add('A 시작'); + await gate.future; + order.add('A 끝'); + }); + final b = q.enqueue('B', () async => order.add('B')); + final c = q.enqueue('C', () async => order.add('C')); + expect([q.busy, q.current, q.pending], [true, 'A', ['B', 'C']]); + expect(logs.where((l) => l.contains('B')), isNotEmpty, reason: '대기열에 넣은 것을 기록'); + expect(q.cancelPending(), 2); + await Future.wait([b, c]); // 빠진 것은 바로 끝난다 + gate.complete(); + await a; + expect(order, ['A 시작', 'A 끝']); + expect([q.busy, q.current], [false, null]); + await q.enqueue('D', () async => order.add('D')); + expect(order.last, 'D'); + }); + + test('약속 AppLog: 최근 500줄 · 파일에도 · 2MB 넘으면 .1 로 옮기고 새로', () { + final log = AppLog()..file = p.join(tmp.path, 'app.log'); + for (var i = 0; i < 510; i++) { + log.add('줄 $i', at: DateTime(2026, 10, 23, 9, 5, 7)); + } + expect(log.lines.length, 500); + expect(log.lines.first, '[09:05:07] 줄 10'); + expect(File(log.file!).readAsLinesSync().last, '2026-10-23 09:05:07 줄 509'); + File(log.file!).writeAsStringSync('x' * (AppLog.maxFileBytes + 1)); + log.add('새 파일'); + expect(File('${log.file}.1').lengthSync(), AppLog.maxFileBytes + 1); + expect(File(log.file!).readAsStringSync(), contains('새 파일')); + (AppLog()..file = p.join(tmp.path, 'no', 'dir', 'x.log')).add('파일을 못 써도 기록은 남는다'); + }); + + test('약속 EncodeChoice: 크기 · 보정은 원본 유지 → H.264 로, 모두 되돌리면 코덱도 되돌림 · 직접 고른 코덱은 그대로', () { + final e = EncodeChoice(); + e.setResolution(ResolutionChoice.values.firstWhere((r) => r != ResolutionChoice.original)); + expect([e.value.codec, e.notice != null], [VideoCodecChoice.h264, true]); + e.notice = null; + e.setResolution(ResolutionChoice.original); + expect([e.value.codec, e.notice != null], [VideoCodecChoice.copy, true], reason: '저절로 바꾼 코덱은 되돌린다'); + e.setCodec(VideoCodecChoice.h264); + e.setResolution(ResolutionChoice.original); + expect(e.value.codec, VideoCodecChoice.h264, reason: '직접 고른 코덱은 되돌리지 않는다'); + e.setQuality(QualityChoice.high); + expect(e.value.quality, QualityChoice.high); + }); + + test('약속 VideoListShare: 한 창이 적은 목록을 다른 창이 따라 하고, 같으면 다시 적지 않는다', () async { + final file = p.join(tmp.path, 'list.json'); + final one = ['a.mp4', 'b.mp4']; + final two = []; + final a = VideoListShare(paths: () => one, apply: (w) async {}); + final b = VideoListShare(paths: () => two, apply: (w) async => two..clear()..addAll(w)); + await a.start(file); + a.stop(); + a.save(); + await b.start(file); + b.stop(); + expect(two, ['a.mp4', 'b.mp4']); + final before = File(file).lastModifiedSync(); + b.save(); // 같은 목록 - 쓰지 않음 + expect(File(file).lastModifiedSync(), before); + expect(tmp.listSync().where((e) => e.path.endsWith('.tmp')), isEmpty, reason: '임시 파일은 이름을 바꿔 남지 않는다'); + }); + + test('약속 VideoFiles: 같은 이름이면 "이름 (2)", 결과물 (이름.mkv · 이름_ko.srt) 만 새 jj_mkv 로', () async { + final src = Directory(p.join(tmp.path, 'src'))..createSync(); + final dst = Directory(p.join(tmp.path, 'dst'))..createSync(); + File(p.join(src.path, 'a.mp4')).writeAsStringSync('v'); + File(p.join(dst.path, 'a.mp4')).writeAsStringSync('old'); + final to = await VideoFiles.moveFileInto(p.join(src.path, 'a.mp4'), dst.path); + expect(p.basename(to), 'a (2).mp4'); + expect(File(p.join(dst.path, 'a.mp4')).readAsStringSync(), 'old'); + final out = Directory(p.join(src.path, 'jj_mkv'))..createSync(); + for (final n in ['a.mkv', 'a_ko.srt', 'a_AI.srt', 'b.mkv', 'a.txt']) { + File(p.join(out.path, n)).writeAsStringSync(n); + } + final n = await VideoFiles.moveResults(p.join(src.path, 'a.mp4'), p.join(dst.path, 'a.mp4'), log: (_) {}); + expect(n, 3); + expect(Directory(p.join(dst.path, 'jj_mkv')).listSync().map((e) => p.basename(e.path)).toSet(), {'a.mkv', 'a_ko.srt', 'a_AI.srt'}); + expect(Directory(out.path).listSync().map((e) => p.basename(e.path)).toSet(), {'b.mkv', 'a.txt'}); + }); + + test('약속 VideoList: 넣기 (같은 것 · jj_mkv 안은 건너뜀) · 정렬 · 체크 · 작업 중인 것은 빼지 않음 · 옮기기', () async { + final saved = []; + final loaded = []; + final list = VideoList(onChanged: () {}, onSave: () => saved.add(1), load: (v) async => loaded.add(v.fileName), log: (_) {}); + final dir = Directory(p.join(tmp.path, 'v'))..createSync(); + String f(String n) => (File(p.join(dir.path, n))..createSync(recursive: true)).path; + await list.add([f('b.mp4'), f('a10.mp4'), f('a2.mp4'), f('b.mp4'), f(p.join('jj_mkv', 'x.mkv'))]); + expect(list.items.map((v) => v.fileName), ['b.mp4', 'a10.mp4', 'a2.mp4']); + expect([list.selected?.fileName, loaded.length, saved.isNotEmpty], ['b.mp4', 3, true]); + list.sort('name'); + expect(list.items.map((v) => v.fileName), ['a2.mp4', 'a10.mp4', 'b.mp4'], reason: '숫자는 크기대로'); + list.toggleChecked(list.items[2]); + expect(list.batchTargets.map((v) => v.fileName), ['b.mp4']); + list.items[2].status = JobStatus.running; + list.removeChecked(); + expect(list.items.length, 3, reason: '작업 중인 것은 빼지 않는다'); + list.items[2].status = JobStatus.ready; + final dest = p.join(tmp.path, 'moved'); + final (moved, errors) = await list.move([list.items.first], dest); + expect([moved, errors], [1, isEmpty]); + expect(File(p.join(dest, 'a2.mp4')).existsSync(), isTrue); + expect(list.items.map((v) => v.fileName), ['a10.mp4', 'b.mp4']); + expect((await list.move(list.items, '')).$1, 0); + list.clear(); + expect([list.items, list.selected], [isEmpty, null]); + }); +} diff --git a/test/restore_access_test.dart b/test/restore_access_test.dart index 9a32da8..e0f3da6 100644 --- a/test/restore_access_test.dart +++ b/test/restore_access_test.dart @@ -4,6 +4,7 @@ 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/controller/video_probe.dart'; import 'package:jj_mkvmaker/app/install_marker.dart'; import 'package:jj_mkvmaker/app/version_snapshot.dart'; import 'package:jj_mkvmaker/core/models.dart'; @@ -140,7 +141,7 @@ void main() { await c.addVideos([video.path], allowOutputFolder: true); final v = c.videos.single; expect(v.info, isNull); - expect(v.message, AppController.accessNeededMessage); + expect(v.message, VideoProbe.accessNeededMessage); expect(v.message, isNot(contains('Permission denied'))); final external = v.subtitles.where((s) => s.kind != SubtitleKind.embedded).length;