app_controller 나누기 (1672 → 506): 일은 lib/app/controller/ 모듈이 하고 화면 상태는 잇기만
- 작업 대기열 (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 <noreply@anthropic.com>modular
parent
aea1ec5eba
commit
5de4412dc2
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,47 @@
|
||||||
|
import '../../core/languages.dart';
|
||||||
|
import '../../services/model_store.dart';
|
||||||
|
|
||||||
|
/// AI 자막 설정
|
||||||
|
class AiOptions {
|
||||||
|
/// 원어 (undetermined = 자동 감지)
|
||||||
|
final Language source;
|
||||||
|
|
||||||
|
/// 만들 언어 (기본: 한국어·영어·일본어)
|
||||||
|
final Set<Language> 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<String> 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<Language>? targets, ModelSpec? whisper}) => AiOptions(
|
||||||
|
source: source ?? this.source, targets: targets ?? this.targets, whisper: whisper ?? this.whisper);
|
||||||
|
}
|
||||||
|
|
@ -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<void> usingModels(Future<void> Function() job) async {
|
||||||
|
_modelUsers++;
|
||||||
|
onChanged();
|
||||||
|
try {
|
||||||
|
await job();
|
||||||
|
} finally {
|
||||||
|
_modelUsers--;
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
_recognizer?.cancel();
|
||||||
|
_translator?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [targets] 를 하나씩 (취소하면 거기서 멈춤)
|
||||||
|
Future<void> run(List<VideoItem> 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<void> _ensureModel(ModelSpec m, List<VideoItem> 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<void> _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<Cue> 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<List<Cue>> _translated(
|
||||||
|
List<Cue> 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<void> translate(VideoItem v, SubtitleEntry s, Set<Language> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// 작업 기록. 약속: [add] 는 "[시:분:초] 글" 을 [lines] 에 (최근 500줄) 남기고, [file] 이 있으면 날짜와 함께 파일에도 쓴다.
|
||||||
|
/// 파일은 2MB 를 넘으면 "이름.1" 로 옮기고 새로 시작한다 (프로그램이 갑자기 꺼져도 무슨 일이 있었는지 볼 수 있게).
|
||||||
|
/// 파일에 쓰지 못해도 기록은 남는다.
|
||||||
|
class AppLog {
|
||||||
|
final List<String> 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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<void> cleanLeftovers(Future<String> 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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<String> pending = [];
|
||||||
|
final List<(Future<void> Function(), Completer<void>)> _runs = [];
|
||||||
|
|
||||||
|
Future<void> enqueue(String label, Future<void> Function() job) {
|
||||||
|
if (busy) {
|
||||||
|
final done = Completer<void>();
|
||||||
|
pending.add(label);
|
||||||
|
_runs.add((job, done));
|
||||||
|
log(trf('대기열에 추가: {0} (대기 {1}개)', [label, pending.length]));
|
||||||
|
onChanged();
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
return _run(label, job);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run(String label, Future<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<VideoItem> _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 = <VideoItem>{};
|
||||||
|
|
||||||
|
/// 지금 묶음을 멈춘다 (만들던 것은 "취소됨", 시작하지 않은 것도 "취소됨")
|
||||||
|
void cancelAll() {
|
||||||
|
_cancelGen++;
|
||||||
|
_cancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 186 · 239: [targets] 동영상만 취소하고 묶음의 다음 것부터 이어서. 돌려주는 것: 이미 끝나 (돌고 있지 않아) 취소하지 못한 것
|
||||||
|
List<VideoItem> cancelVideos(List<VideoItem> 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<void> run(List<VideoItem> queue,
|
||||||
|
{required String existing, required EncodeSettings encode, required Set<String> 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 = <VideoItem>{};
|
||||||
|
// 262: 동영상마다 끝났을 때의 결과 (집계는 이것으로 - 끝난 뒤 줄의 상태가 바뀌어도 기록과 같게)
|
||||||
|
final results = <VideoItem, JobStatus>{};
|
||||||
|
Future<void> 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<bool> _build(VideoItem v, String existing, EncodeSettings encode, Set<String> 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 = <SubtitleEntry, String>{};
|
||||||
|
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<List<VideoItem>> withExistingOutput(List<VideoItem> targets) async => [
|
||||||
|
for (final v in targets)
|
||||||
|
if (await storage.exists(outputMkvPath(v.path))) v,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// "이름 (2).mkv" 처럼 비어 있는 이름 (P0-1 [다른 이름])
|
||||||
|
Future<String> _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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>, int)?> prepare(List<String> files, PlaybackSettings settings,
|
||||||
|
{bool keepOrder = false,
|
||||||
|
bool internal = false,
|
||||||
|
Future<void> Function(String program, List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SubtitleQuery> queryFor(VideoItem v, List<Language> 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<List<SubtitleSearchResult>> 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<int> download(VideoItem v, List<SubtitleSearchResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SubtitleEntry> 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<List<Cue>> 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<String?> 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<Cue> 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<String> 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<List<String>> externalFor(String video) async {
|
||||||
|
final out = <String>[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> 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<int> 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<File>()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<void> 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<VideoItem> items = [];
|
||||||
|
VideoItem? selected;
|
||||||
|
|
||||||
|
/// 체크한 동영상 (일괄 자막 만들기 · MKV 만들기 · 제거 대상)
|
||||||
|
final Set<VideoItem> checked = {};
|
||||||
|
|
||||||
|
/// 마지막으로 누른 정렬 ('name' · 'date') 과 방향. 같은 것을 다시 누르면 반대로.
|
||||||
|
String? sortedBy;
|
||||||
|
bool sortAscending = true;
|
||||||
|
|
||||||
|
/// [allowOutputFolder]: 탐색기에서 직접 연 파일은 jj_mkv 폴더 안에 있어도 넣는다
|
||||||
|
Future<void> add(List<String> 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<int> 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<void> follow(List<String> 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 = <VideoItem, DateTime>{};
|
||||||
|
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<VideoItem> 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<String>)> move(List<VideoItem> targets, String dest) async {
|
||||||
|
if (dest.isEmpty) return (0, [tr('이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)')]);
|
||||||
|
var moved = 0;
|
||||||
|
final errors = <String>[];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// 동영상 목록 저장 · 창끼리 공유. 목록 (경로) 을 파일 하나에 적어 두고, 다시 켜면 그대로 불러오며, 창이 여러 개면
|
||||||
|
/// 1초마다 파일을 보고 다른 창에서 넣거나 지운 것을 따라 한다.
|
||||||
|
/// 약속: [save] 는 [paths] 가 바뀌었을 때만 임시 파일 → 이름 바꾸기로 쓴다 (다른 창이 반쯤 쓴 파일을 보지 않게).
|
||||||
|
/// [sync] 는 파일이 바뀌었으면 원하는 목록을 [apply] 에 넘긴다 (그동안 [save] 는 쉬고, 끝나면 한 번 다시 적는다).
|
||||||
|
class VideoListShare {
|
||||||
|
/// 지금 목록의 경로들
|
||||||
|
final List<String> Function() paths;
|
||||||
|
|
||||||
|
/// 파일에 적힌 목록에 맞춘다 (다른 창이 바꾼 것)
|
||||||
|
final Future<void> Function(List<String> want) apply;
|
||||||
|
VideoListShare({required this.paths, required this.apply});
|
||||||
|
|
||||||
|
String? _file;
|
||||||
|
String _saved = '[]';
|
||||||
|
Timer? _timer;
|
||||||
|
bool _syncing = false;
|
||||||
|
|
||||||
|
/// 저장 · 공유 시작 ([file] 을 먼저 불러온다)
|
||||||
|
Future<void> 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<void> 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<String> want;
|
||||||
|
try {
|
||||||
|
want = (jsonDecode(text) as List).cast<String>();
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_syncing = true;
|
||||||
|
try {
|
||||||
|
_saved = text;
|
||||||
|
await apply(want);
|
||||||
|
} finally {
|
||||||
|
_syncing = false;
|
||||||
|
save(); // 없는 파일을 뺐거나 맞추는 동안 이 창에서 바꾼 것이 있으면 다시 적는다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<bool> 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<void> 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<String> 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<String> 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));
|
||||||
|
}
|
||||||
|
|
@ -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 = <String>[];
|
||||||
|
final q = JobQueue(onChanged: () {}, log: logs.add);
|
||||||
|
final order = <String>[];
|
||||||
|
final gate = Completer<void>();
|
||||||
|
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 = <String>['a.mp4', 'b.mp4'];
|
||||||
|
final two = <String>[];
|
||||||
|
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 = <int>[];
|
||||||
|
final loaded = <String>[];
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jj_mkvmaker/app/app_controller.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/install_marker.dart';
|
||||||
import 'package:jj_mkvmaker/app/version_snapshot.dart';
|
import 'package:jj_mkvmaker/app/version_snapshot.dart';
|
||||||
import 'package:jj_mkvmaker/core/models.dart';
|
import 'package:jj_mkvmaker/core/models.dart';
|
||||||
|
|
@ -140,7 +141,7 @@ void main() {
|
||||||
await c.addVideos([video.path], allowOutputFolder: true);
|
await c.addVideos([video.path], allowOutputFolder: true);
|
||||||
final v = c.videos.single;
|
final v = c.videos.single;
|
||||||
expect(v.info, isNull);
|
expect(v.info, isNull);
|
||||||
expect(v.message, AppController.accessNeededMessage);
|
expect(v.message, VideoProbe.accessNeededMessage);
|
||||||
expect(v.message, isNot(contains('Permission denied')));
|
expect(v.message, isNot(contains('Permission denied')));
|
||||||
final external = v.subtitles.where((s) => s.kind != SubtitleKind.embedded).length;
|
final external = v.subtitles.where((s) => s.kind != SubtitleKind.embedded).length;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue