diff --git a/assets/l10n/en.json b/assets/l10n/en.json index be3b6e0..223e26d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -49,8 +49,10 @@ "AI 자막 중단: {0}": "AI subtitles stopped: {0}", "AI 자막 취소: {0}": "AI subtitles cancelled: {0}", "AI 자막: {0}": "AI subtitles: {0}", + "AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "Leftovers from jobs: audio for AI subtitles (wav) · converted subtitle copies · preview images", "AI 자막을 만들고 이어서 MKV 로 만듭니다": "makes AI subtitles and then makes the MKV", "AI 자막을 만듭니다": "makes AI subtitles", + "AI 작업 중이라 이번에는 건너뜁니다": "Skipped this time because an AI job is running", "API 키": "API key", "API 키 (필수, 무료)": "API key (required, free)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)", @@ -82,6 +84,7 @@ "MKV 만들기": "Make MKV", "MKV 만들기 (대기열에 추가)": "Make MKV (add to queue)", "MKV 만들기 {0}개": "Make {0} MKVs", + "MKV 만들기 · AI 자막 작업 중이라 이번에는 건너뜁니다": "Skipped this time because a Make MKV · AI subtitle job is running", "MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.": "Update after the Make MKV · AI subtitle jobs finish.", "MKV 만들기 · 인코딩 · 음성 추출": "Make MKV · encoding · audio extraction", "MKV 만들기 목록에 동영상 {0}개를 추가했습니다.": "Added {0} videos to the Make MKV list.", @@ -159,6 +162,7 @@ "{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed", "{0}개": "{0}", + "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 선택": "{0} selected", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", @@ -233,6 +237,7 @@ "내려받기 실패 ({0})": "Download failed ({0})", "내려받기 실패 ({0}): {1}": "Download failed ({0}): {1}", "내려받는 중": "Downloading", + "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)", "내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine", "내보냈습니다: {0}": "Exported: {0}", "내용": "Content", @@ -360,10 +365,14 @@ "바로 재생 (기본)": "Play right away (default)", "바로 전 작업: {0}": "Previous job: {0}", "받는 중": "Downloading", + "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "Skipped because downloads are running or paused (so they can resume)", "받는 중인 다운로드 {0}개": "{0} downloads in progress", "받는 중인 다운로드가 없습니다.": "Nothing is downloading.", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept", + "받다 만 AI 모델": "Unfinished AI models", + "받다 만 다운로드": "Unfinished downloads", "받은 동영상을 편집 목록에 추가: {0}": "Added the received video to the edit list: {0}", + "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "Temporary files left from installing updates · required programs · the Chrome engine", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)", "받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", @@ -480,6 +489,7 @@ "언어: 미지정": "Language: unspecified", "언어를 더하지 못했습니다: {0}": "Could not add the language: {0}", "업데이트": "Update", + "업데이트 · 설치하고 남은 파일": "Update · install leftovers", "업데이트 실패: {0}": "Update failed: {0}", "없음": "None", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", @@ -497,6 +507,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})", + "오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "Old job log files (app.log.1 etc.). The current log is kept", "오른쪽 90°": "Right 90°", "오픈 소스 라이선스": "Open source licenses", "올린 이 {0}": "Uploaded by {0}", @@ -585,6 +596,7 @@ "일반": "General", "일본어": "Japanese", "일시정지": "Pause", + "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 감지": "Auto-detect", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "Automatic installation is not possible here. Please download it from the page yourself.", @@ -623,6 +635,7 @@ "작업": "Jobs", "작업 기록 보기": "Show job log", "작업 기록 숨기기": "Hide job log", + "작업 임시 파일": "Job temporary files", "작업 중": "Working", "작업 중지: {0}{1}": "Job stopped: {0}{1}", "작업 취소": "Cancel job", @@ -630,6 +643,7 @@ "작업 현황 닫기": "Close job status", "작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)": "Show job status · split screen (Ctrl+Shift+J)", "작업 확인 (진행 중인 작업 없음)": "Job check (no jobs in progress)", + "작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · 업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. 받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다": "Finds and deletes temporary files left from jobs · unfinished downloads (.part etc.) · unfinished AI models · update leftovers · old job logs. Downloaded videos · made MKVs · subtitles are not deleted, and anything used by running jobs · downloads is skipped", "작은 용량": "Small size", "잘못된 varint (위치 {0})": "Invalid varint (position {0})", "재개": "Resume", @@ -647,6 +661,8 @@ "저장": "Save", "저장 (MKV 반영)": "Save (apply to MKV)", "저장 (MKV 반영) *": "Save (apply to MKV) *", + "저장 공간 정리": "Storage cleanup", + "저장 공간 정리: 파일 {0}개 · {1}": "Storage cleanup: {0} files · {1}", "저장 실패: {0}": "Save failed: {0}", "저장 위치": "Save locations", "저장: {0}": "Save: {0}", @@ -662,6 +678,12 @@ "전체 화면": "Full screen", "전체 화면 (F)": "Full screen (F)", "점검": "Check", + "정리": "Clean up", + "정리 ({0})": "Clean up ({0})", + "정리…": "Clean up…", + "정리할 파일을 찾는 중…": "Looking for files to clean up…", + "정리할 파일이 없었습니다.": "There was nothing to clean up.", + "정리했습니다: 파일 {0}개 · {1}": "Cleaned up: {0} files · {1}", "정사각 1:1": "Square 1:1", "정지": "Stop", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "Not set: you choose it the first time you press [Move] in the MKV list", @@ -702,6 +724,7 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금: {0}": "Now: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", + "지난 작업 기록": "Old job logs", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", "직접 입력": "Custom", @@ -731,6 +754,7 @@ "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "Added: {0} ({1} embedded subtitles, {2} subtitles in the same folder)", "출력: {0}": "Output: {0}", "취소": "Cancel", + "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)", "취소됨": "Cancelled", "취소했습니다.": "Cancelled.", "켜져 있는 창에서 (기본)": "In the open window (default)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 273591c..c514d1c 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -49,8 +49,10 @@ "AI 자막 중단: {0}": "AI 字幕中断: {0}", "AI 자막 취소: {0}": "AI 字幕キャンセル: {0}", "AI 자막: {0}": "AI 字幕: {0}", + "AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "作業の残り: AI 字幕用の音声(wav)・字幕変換のコピー・プレビュー画像など", "AI 자막을 만들고 이어서 MKV 로 만듭니다": "AI 字幕を作成し、続けて MKV を作成します", "AI 자막을 만듭니다": "AI 字幕を作成します", + "AI 작업 중이라 이번에는 건너뜁니다": "AI 作業中のため今回はスキップします", "API 키": "API キー", "API 키 (필수, 무료)": "API キー(必須・無料)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", @@ -82,6 +84,7 @@ "MKV 만들기": "MKV 作成", "MKV 만들기 (대기열에 추가)": "MKV 作成(キューに追加)", "MKV 만들기 {0}개": "MKV 作成 {0}個", + "MKV 만들기 · AI 자막 작업 중이라 이번에는 건너뜁니다": "MKV 作成・AI 字幕の作業中のため今回はスキップします", "MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.": "MKV 作成・AI 字幕の作業が終わってから更新してください。", "MKV 만들기 · 인코딩 · 음성 추출": "MKV 作成・エンコード・音声抽出", "MKV 만들기 목록에 동영상 {0}개를 추가했습니다.": "MKV 作成リストに動画を {0}個追加しました。", @@ -159,6 +162,7 @@ "{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした", "{0}개": "{0}個", + "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 선택": "{0}個選択", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", @@ -233,6 +237,7 @@ "내려받기 실패 ({0})": "ダウンロード失敗({0})", "내려받기 실패 ({0}): {1}": "ダウンロード失敗({0}): {1}", "내려받는 중": "ダウンロード中", + "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", "내보냈습니다: {0}": "書き出しました: {0}", "내용": "内容", @@ -360,10 +365,14 @@ "바로 재생 (기본)": "すぐ再生(既定)", "바로 전 작업: {0}": "直前の作業: {0}", "받는 중": "ダウンロード中", + "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "ダウンロード中または一時停止中のものがあるためスキップします(再開できるように)", "받는 중인 다운로드 {0}개": "ダウンロード中 {0}個", "받는 중인 다운로드가 없습니다.": "ダウンロード中のものはありません。", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま", + "받다 만 AI 모델": "ダウンロード途中の AI モデル", + "받다 만 다운로드": "ダウンロード途中のファイル", "받은 동영상을 편집 목록에 추가: {0}": "受け取った動画を編集リストに追加: {0}", + "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)", "받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", @@ -480,6 +489,7 @@ "언어: 미지정": "言語: 未指定", "언어를 더하지 못했습니다: {0}": "言語を追加できませんでした: {0}", "업데이트": "更新", + "업데이트 · 설치하고 남은 파일": "更新・インストールの残り", "업데이트 실패: {0}": "更新失敗: {0}", "없음": "なし", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", @@ -497,6 +507,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})", + "오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "古い作業記録ファイル(app.log.1 など)。現在の記録は残します", "오른쪽 90°": "右 90°", "오픈 소스 라이선스": "オープンソースライセンス", "올린 이 {0}": "投稿者 {0}", @@ -585,6 +596,7 @@ "일반": "一般", "일본어": "日本語", "일시정지": "一時停止", + "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 감지": "自動検出", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "自動インストールできない場所です。ページから直接ダウンロードしてください。", @@ -623,6 +635,7 @@ "작업": "作業", "작업 기록 보기": "作業記録を表示", "작업 기록 숨기기": "作業記録を隠す", + "작업 임시 파일": "作業の一時ファイル", "작업 중": "作業中", "작업 중지: {0}{1}": "作業中止: {0}{1}", "작업 취소": "作業をキャンセル", @@ -630,6 +643,7 @@ "작업 현황 닫기": "作業状況を閉じる", "작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)": "作業状況を表示・画面分割(Ctrl+Shift+J)", "작업 확인 (진행 중인 작업 없음)": "作業確認(進行中の作業なし)", + "작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · 업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. 받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다": "作業で残った一時ファイル・ダウンロード途中のファイル(.part など)・途中の AI モデル・更新の残り・古い作業記録を探して削除します。ダウンロードした動画・作成した MKV・字幕は削除せず、進行中の作業・ダウンロードが使っているものはスキップします", "작은 용량": "小さい容量", "잘못된 varint (위치 {0})": "不正な varint(位置 {0})", "재개": "再開", @@ -647,6 +661,8 @@ "저장": "保存", "저장 (MKV 반영)": "保存(MKV に反映)", "저장 (MKV 반영) *": "保存(MKV に反映)*", + "저장 공간 정리": "ストレージの整理", + "저장 공간 정리: 파일 {0}개 · {1}": "ストレージの整理: ファイル {0}個・{1}", "저장 실패: {0}": "保存失敗: {0}", "저장 위치": "保存先", "저장: {0}": "保存: {0}", @@ -662,6 +678,12 @@ "전체 화면": "全画面", "전체 화면 (F)": "全画面(F)", "점검": "点検", + "정리": "整理", + "정리 ({0})": "整理({0})", + "정리…": "整理…", + "정리할 파일을 찾는 중…": "整理するファイルを探しています…", + "정리할 파일이 없었습니다.": "整理するファイルはありませんでした。", + "정리했습니다: 파일 {0}개 · {1}": "整理しました: ファイル {0}個・{1}", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未設定: MKV リストで[移動]を初めて押したときに選びます", @@ -702,6 +724,7 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금: {0}": "現在: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", + "지난 작업 기록": "過去の作業記録", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", "직접 입력": "直接入力", @@ -731,6 +754,7 @@ "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "追加: {0}(内蔵字幕 {1}個、同じフォルダーの字幕 {2}個)", "출력: {0}": "出力: {0}", "취소": "キャンセル", + "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)", "취소됨": "キャンセル済み", "취소했습니다.": "キャンセルしました。", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index cf14ba6..92664e9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -49,8 +49,10 @@ "AI 자막 중단: {0}": "AI 字幕中断: {0}", "AI 자막 취소: {0}": "AI 字幕已取消: {0}", "AI 자막: {0}": "AI 字幕: {0}", + "AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일": "任务残留: AI 字幕用音频(wav)· 字幕转换副本 · 预览图片等", "AI 자막을 만들고 이어서 MKV 로 만듭니다": "生成 AI 字幕后接着制作 MKV", "AI 자막을 만듭니다": "生成 AI 字幕", + "AI 작업 중이라 이번에는 건너뜁니다": "正在进行 AI 任务,本次跳过", "API 키": "API 密钥", "API 키 (필수, 무료)": "API 密钥(必填,免费)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", @@ -82,6 +84,7 @@ "MKV 만들기": "制作 MKV", "MKV 만들기 (대기열에 추가)": "制作 MKV(加入队列)", "MKV 만들기 {0}개": "制作 MKV {0} 个", + "MKV 만들기 · AI 자막 작업 중이라 이번에는 건너뜁니다": "正在制作 MKV · AI 字幕,本次跳过", "MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.": "请在制作 MKV · AI 字幕任务完成后再更新。", "MKV 만들기 · 인코딩 · 음성 추출": "制作 MKV · 编码 · 音频提取", "MKV 만들기 목록에 동영상 {0}개를 추가했습니다.": "已向制作 MKV 列表添加 {0} 个视频。", @@ -159,6 +162,7 @@ "{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动", "{0}개": "{0} 个", + "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 선택": "已选 {0} 个", "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", @@ -233,6 +237,7 @@ "내려받기 실패 ({0})": "下载失败({0})", "내려받기 실패 ({0}): {1}": "下载失败({0}): {1}", "내려받는 중": "正在下载", + "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", "내보냈습니다: {0}": "已导出: {0}", "내용": "内容", @@ -360,10 +365,14 @@ "바로 재생 (기본)": "立即播放(默认)", "바로 전 작업: {0}": "上一个任务: {0}", "받는 중": "正在下载", + "받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)": "有正在下载或已暂停的下载,已跳过(以便继续下载)", "받는 중인 다운로드 {0}개": "正在下载 {0} 个", "받는 중인 다운로드가 없습니다.": "没有正在进行的下载。", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留", + "받다 만 AI 모델": "未下载完的 AI 模型", + "받다 만 다운로드": "未完成的下载", "받은 동영상을 편집 목록에 추가: {0}": "已将收到的视频加入编辑列表: {0}", + "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)", "받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", @@ -480,6 +489,7 @@ "언어: 미지정": "语言: 未指定", "언어를 더하지 못했습니다: {0}": "未能添加语言: {0}", "업데이트": "更新", + "업데이트 · 설치하고 남은 파일": "更新 · 安装残留", "업데이트 실패: {0}": "更新失败: {0}", "없음": "无", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", @@ -497,6 +507,7 @@ "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})", + "오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다": "旧的任务记录文件(app.log.1 等)。当前记录会保留", "오른쪽 90°": "向右 90°", "오픈 소스 라이선스": "开源许可", "올린 이 {0}": "上传者 {0}", @@ -585,6 +596,7 @@ "일반": "常规", "일본어": "日语", "일시정지": "暂停", + "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 감지": "自动检测", "자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.": "此位置无法自动安装。请从页面手动下载。", @@ -623,6 +635,7 @@ "작업": "任务", "작업 기록 보기": "显示任务记录", "작업 기록 숨기기": "隐藏任务记录", + "작업 임시 파일": "任务临时文件", "작업 중": "处理中", "작업 중지: {0}{1}": "任务已停止: {0}{1}", "작업 취소": "取消任务", @@ -630,6 +643,7 @@ "작업 현황 닫기": "关闭任务状态", "작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)": "显示任务状态 · 分屏(Ctrl+Shift+J)", "작업 확인 (진행 중인 작업 없음)": "任务检查(没有进行中的任务)", + "작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · 업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. 받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다": "查找并删除任务残留的临时文件 · 未完成的下载(.part 等)· 未下载完的 AI 模型 · 更新残留 · 旧任务记录。不会删除已下载的视频 · 制作的 MKV · 字幕,正在进行的任务 · 下载所用的文件会跳过", "작은 용량": "小体积", "잘못된 varint (위치 {0})": "无效的 varint(位置 {0})", "재개": "继续", @@ -647,6 +661,8 @@ "저장": "保存", "저장 (MKV 반영)": "保存(应用到 MKV)", "저장 (MKV 반영) *": "保存(应用到 MKV)*", + "저장 공간 정리": "存储空间清理", + "저장 공간 정리: 파일 {0}개 · {1}": "存储空间清理: 文件 {0} 个 · {1}", "저장 실패: {0}": "保存失败: {0}", "저장 위치": "保存位置", "저장: {0}": "保存: {0}", @@ -662,6 +678,12 @@ "전체 화면": "全屏", "전체 화면 (F)": "全屏(F)", "점검": "检查", + "정리": "清理", + "정리 ({0})": "清理({0})", + "정리…": "清理…", + "정리할 파일을 찾는 중…": "正在查找可清理的文件…", + "정리할 파일이 없었습니다.": "没有可清理的文件。", + "정리했습니다: 파일 {0}개 · {1}": "已清理: 文件 {0} 个 · {1}", "정사각 1:1": "正方形 1:1", "정지": "停止", "정하지 않음: MKV 목록에서 [이동] 을 처음 누를 때 고릅니다": "未设置: 首次在 MKV 列表中点击[移动]时选择", @@ -702,6 +724,7 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금: {0}": "当前: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", + "지난 작업 기록": "旧任务记录", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", "직접 입력": "手动输入", @@ -731,6 +754,7 @@ "추가: {0} (내장 자막 {1}개, 같은 폴더 자막 {2}개)": "已添加: {0}(内嵌字幕 {1} 个,同文件夹字幕 {2} 个)", "출력: {0}": "输出: {0}", "취소": "取消", + "취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)", "취소됨": "已取消", "취소했습니다.": "已取消。", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 91b90bb..90a5071 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -885,7 +885,12 @@ class AppController extends ChangeNotifier { ..message = e is MediaToolException ? e.message : '$e'; _log(trf('AI 자막 실패: {0}\n{1}', [v.fileName, v.message])); } finally { - await storage.delete(wav); + // 작업 폴더째 지운다 (음성인식이 만드는 변환 사본 audio.wav.wav 도 함께) + try { + await Directory(tmp).delete(recursive: true); + } catch (_) { + await storage.delete(wav); + } notifyListeners(); } } diff --git a/lib/app/cleanup.dart b/lib/app/cleanup.dart new file mode 100644 index 0000000..611c095 --- /dev/null +++ b/lib/app/cleanup.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../l10n/tr.dart'; +import 'app_controller.dart'; +import 'download_manager.dart'; + +/// 정리할 파일 · 폴더 하나 +class CleanupItem { + final String path; + final int bytes; + final bool isDir; + const CleanupItem(this.path, this.bytes, {this.isDir = false}); +} + +/// 정리 묶음 (작업 임시 파일 · 받다 만 다운로드 …) +class CleanupGroup { + final String id; + final String title; + final String hint; + final List items; + + /// 작업 중이라 이번에는 건너뛰는 이유 (있으면 지우지 않음) + final String? skipped; + CleanupGroup(this.id, this.title, this.hint, this.items, {this.skipped}); + + int get bytes => items.fold(0, (a, i) => a + i.bytes); +} + +/// 저장 공간 정리 (환경 설정 > 저장 공간 정리): 작업하다 남은 임시 파일 · 받다 만 다운로드 조각 (.part 등) · +/// 받다 만 AI 모델 · 업데이트 · 설치하고 남은 파일 · 지난 작업 기록을 찾아 지운다. +/// 진행 중인 작업 · 다운로드가 쓰는 곳은 건너뛴다 (이어받기 · 작업이 깨지지 않게). +class Cleaner { + final AppController c; + final DownloadManager? downloads; + + /// 앱 설정 폴더 (테스트에서 바꿔 끼움) + final String? dataDir; + + /// 시스템 임시 폴더 (Windows 의 업데이트 · 도구 설치 남은 파일. 테스트에서 바꿔 끼움) + final String? systemTemp; + + Cleaner(this.c, {this.downloads, this.dataDir, this.systemTemp}); + + /// 받다 만 다운로드 조각: yt-dlp (.part · .ytdl · .part-Frag · .temp. · 합치기 전 영상.f137.mp4) · aria2 (.aria2) + static final downloadLeftover = + RegExp(r'\.part$|\.part-frag\d+|\.ytdl$|\.temp\.|\.f\d+\.[a-z0-9]+$|\.aria2$', caseSensitive: false); + + static int _size(FileSystemEntity e) { + try { + if (e is File) return e.lengthSync(); + if (e is Directory) { + var n = 0; + for (final f in e.listSync(recursive: true, followLinks: false)) { + if (f is File) n += f.lengthSync(); + } + return n; + } + } catch (_) {} + return 0; + } + + static List _list(String dir, {bool recursive = false}) { + try { + final d = Directory(dir); + if (!d.existsSync()) return const []; + return d.listSync(recursive: recursive, followLinks: false); + } catch (_) { + return const []; + } + } + + CleanupItem _item(FileSystemEntity e) => CleanupItem(e.path, _size(e), isDir: e is Directory); + + /// 정리할 것 찾기 + Future> scan() async { + final working = c.busy; + final downloading = (downloads?.activeCount ?? 0) > 0; + final data = dataDir ?? (await getApplicationSupportDirectory()).path; + final groups = []; + + // 1. 작업 임시 파일: 앱 임시 폴더 전체 + 설정 파일을 저장하다 남은 이름.12345.tmp + final tmpDir = await c.services.storage.tempDirectory(); + groups.add(CleanupGroup( + 'work', + tr('작업 임시 파일'), + tr('AI 자막용 음성 (wav) · 자막 변환 사본 · 미리보기 그림 등 작업하다 남은 파일'), + [ + for (final e in _list(tmpDir)) _item(e), + for (final e in _list(data)) + if (e is File && RegExp(r'\.\d+\.tmp$').hasMatch(e.path)) _item(e), + ], + skipped: working ? tr('MKV 만들기 · AI 자막 작업 중이라 이번에는 건너뜁니다') : null, + )); + + // 2. 받다 만 다운로드 조각 + final s = c.settings; + groups.add(CleanupGroup( + 'download', + tr('받다 만 다운로드'), + tr('취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)'), + [ + for (final dir in {s.ytDlpDir, s.aria2Dir}) + for (final e in _list(dir, recursive: true)) + if (e is File && downloadLeftover.hasMatch(p.basename(e.path))) _item(e), + ], + skipped: downloading ? tr('받는 중이거나 일시정지한 다운로드가 있어 건너뜁니다 (이어받기가 깨지지 않게)') : null, + )); + + // 3. 받다 만 AI 모델 (.part) · 모델 변환하다 남은 .tmp + final models = await c.services.models.directory(); + groups.add(CleanupGroup( + 'models', + tr('받다 만 AI 모델'), + tr('내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)'), + [ + for (final e in _list(models, recursive: true)) + if (e is File && (e.path.endsWith('.part') || e.path.endsWith('.tmp'))) _item(e), + ], + skipped: working ? tr('AI 작업 중이라 이번에는 건너뜁니다') : null, + )); + + // 4. 업데이트 · 도구 설치하고 남은 파일 (Windows 임시 폴더) + final sysTmp = systemTemp ?? (Platform.isWindows ? Directory.systemTemp.path : null); + if (sysTmp != null) { + final mine = RegExp(r'^(jj_mkvmaker_update|jj_cef_|jj_tool_|jj_[\w.-]+\.download$|jj_mkvmaker_update\.(ps1|log)$)'); + groups.add(CleanupGroup( + 'update', + tr('업데이트 · 설치하고 남은 파일'), + tr('받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일'), + [ + for (final e in _list(sysTmp)) + if (mine.hasMatch(p.basename(e.path))) _item(e), + ], + )); + } + + // 5. 지난 작업 기록 (지금 쓰는 app.log 는 남김) + final log = c.logFile; + if (log != null) { + groups.add(CleanupGroup( + 'logs', + tr('지난 작업 기록'), + tr('오래된 작업 기록 파일 (app.log.1 등). 지금 쓰는 기록은 남깁니다'), + [ + for (final e in _list(p.dirname(log))) + if (e is File && !p.equals(e.path, log)) _item(e), + ], + )); + } + return groups; + } + + /// 고른 묶음 지우기 (건너뛴 묶음은 지우지 않음). (지운 개수, 비운 용량) + Future<(int, int)> clean(Iterable groups) async { + var n = 0, bytes = 0; + for (final g in groups) { + if (g.skipped != null) continue; + for (final i in g.items) { + try { + if (i.isDir) { + await Directory(i.path).delete(recursive: true); + } else { + await File(i.path).delete(); + } + n++; + bytes += i.bytes; + } catch (_) { + // 쓰는 중이거나 지울 수 없는 파일은 그대로 + } + } + } + return (n, bytes); + } + + /// "12.3 MB" · "850 KB" + static String sizeText(int bytes) { + if (bytes >= 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + if (bytes >= 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (bytes >= 1024) return '${(bytes / 1024).round()} KB'; + return '$bytes B'; + } +} diff --git a/lib/ui/cleanup_dialog.dart b/lib/ui/cleanup_dialog.dart new file mode 100644 index 0000000..0863b12 --- /dev/null +++ b/lib/ui/cleanup_dialog.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../app/cleanup.dart'; +import '../app/download_manager.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 저장 공간 정리: 찾은 묶음별 크기를 보여 주고, 고른 것만 지운다 +Future showCleanup(BuildContext context, AppController c, DownloadManager? downloads) async { + final cleaner = Cleaner(c, downloads: downloads); + final messenger = ScaffoldMessenger.maybeOf(context); + final result = await showDialog<(int, int)>( + context: context, + builder: (_) => _CleanupDialog(cleaner: cleaner), + ); + if (result == null) return; + final (n, bytes) = result; + c.note(trf('저장 공간 정리: 파일 {0}개 · {1}', [n, Cleaner.sizeText(bytes)])); + messenger?.showSnackBar(SnackBar( + content: Text(n == 0 ? tr('정리할 파일이 없었습니다.') : trf('정리했습니다: 파일 {0}개 · {1}', [n, Cleaner.sizeText(bytes)])))); +} + +class _CleanupDialog extends StatefulWidget { + final Cleaner cleaner; + const _CleanupDialog({required this.cleaner}); + + @override + State<_CleanupDialog> createState() => _CleanupDialogState(); +} + +class _CleanupDialogState extends State<_CleanupDialog> { + List? _groups; + final _picked = {}; + bool _cleaning = false; + + @override + void initState() { + super.initState(); + widget.cleaner.scan().then((g) { + if (!mounted) return; + setState(() { + _groups = g; + // 처음에는 지울 수 있고 무언가 있는 묶음을 모두 고른 상태로 + _picked.addAll([for (final x in g) if (x.skipped == null && x.items.isNotEmpty) x.id]); + }); + }); + } + + int get _total => [ + for (final g in _groups ?? const []) + if (_picked.contains(g.id)) g.bytes, + ].fold(0, (a, b) => a + b); + + @override + Widget build(BuildContext context) { + final groups = _groups; + return AlertDialog( + title: Row(children: [ + const Icon(Icons.cleaning_services_outlined, color: JjColors.accent), + const SizedBox(width: 10), + Text(tr('저장 공간 정리')), + ]), + content: SizedBox( + width: 560, + child: groups == null + ? Padding( + padding: const EdgeInsets.all(24), + child: Row(children: [ + const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox(width: 12), + Text(tr('정리할 파일을 찾는 중…')), + ]), + ) + : ListView(shrinkWrap: true, children: [ + for (final g in groups) + CheckboxListTile( + value: g.skipped == null && _picked.contains(g.id), + onChanged: g.skipped != null || g.items.isEmpty || _cleaning + ? null + : (v) => setState(() => v! ? _picked.add(g.id) : _picked.remove(g.id)), + title: Row(children: [ + Expanded(child: Text(g.title)), + Text( + g.items.isEmpty ? tr('없음') : trf('{0}개 · {1}', [g.items.length, Cleaner.sizeText(g.bytes)]), + style: const TextStyle(fontSize: 13, color: JjColors.textDim), + ), + ]), + subtitle: Text(g.skipped ?? g.hint, + style: TextStyle(fontSize: 12, color: g.skipped != null ? Colors.amber : JjColors.textDim)), + ), + ]), + ), + actions: [ + TextButton(onPressed: _cleaning ? null : () => Navigator.pop(context), child: Text(tr('닫기'))), + FilledButton.icon( + onPressed: groups == null || _picked.isEmpty || _total == 0 && !_hasItems || _cleaning + ? null + : () async { + setState(() => _cleaning = true); + final r = await widget.cleaner.clean(groups.where((g) => _picked.contains(g.id))); + if (context.mounted) Navigator.pop(context, r); + }, + icon: _cleaning + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.delete_sweep_outlined, size: 18), + label: Text(groups == null ? tr('정리') : trf('정리 ({0})', [Cleaner.sizeText(_total)])), + ), + ], + ); + } + + bool get _hasItems => (_groups ?? const []).any((g) => _picked.contains(g.id) && g.items.isNotEmpty); +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index b16e042..9584726 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -19,6 +19,7 @@ import 'folder_picker.dart'; import 'language_settings.dart'; import 'app_actions.dart'; import 'cef_setup.dart'; +import 'cleanup_dialog.dart'; import 'theme.dart'; import '../l10n/tr.dart'; @@ -652,6 +653,20 @@ class _SettingsPageState extends State { ), ), ]), + _group('cleanup', Icons.cleaning_services_outlined, tr('저장 공간 정리'), [ + ListTile( + title: Text(tr('임시 파일 · 남은 조각 정리')), + subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · ' + '업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. ' + '받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다')), + isThreeLine: true, + trailing: FilledButton.icon( + onPressed: () => showCleanup(context, c, AppScope.maybeOf(context)?.downloads), + icon: const Icon(Icons.cleaning_services_outlined, size: 18), + label: Text(tr('정리…')), + ), + ), + ]), _group('about', Icons.info_outline, tr('프로그램 정보'), [ if (desk) ...[ ListTile( @@ -693,7 +708,9 @@ class _SettingsPageState extends State { /// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤) final _groupKeys = {}; - static const _groupIds = ['general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'download', 'run', 'about']; + static const _groupIds = [ + 'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'download', 'run', 'cleanup', 'about', // + ]; String _groupTitle(String id) => switch (id) { 'general' => tr('일반'), @@ -704,6 +721,7 @@ class _SettingsPageState extends State { 'browser' => tr('웹 브라우저'), 'download' => tr('다운로드'), 'run' => tr('실행 · 종료'), + 'cleanup' => tr('저장 공간 정리'), _ => tr('프로그램 정보'), }; diff --git a/test/cleanup_test.dart b/test/cleanup_test.dart new file mode 100644 index 0000000..c467fff --- /dev/null +++ b/test/cleanup_test.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/cleanup.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/services/model_store.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +/// 앱 임시 폴더를 시험 폴더로 +class _Storage extends DesktopStorageService { + final String tmp; + _Storage(this.tmp); + @override + Future tempDirectory() async => tmp; +} + +class _Backend implements DownloadBackend { + @override + DownloadKind get kind => DownloadKind.video; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading; + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +void main() { + late Directory root; + late AppController c; + late String tmp, data, models, sysTmp, logs; + + File make(String path, [int size = 100]) => File(path) + ..parent.createSync(recursive: true) + ..writeAsBytesSync(List.filled(size, 1)); + + setUp(() { + root = Directory.systemTemp.createTempSync('jj_clean_'); + tmp = p.join(root.path, 'apptmp'); + data = p.join(root.path, 'data'); + models = p.join(root.path, 'models'); + sysTmp = p.join(root.path, 'systemp'); + logs = p.join(data, 'Logs'); + c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: _Storage(tmp), + models: ModelStore(models), + )); + c.settings.downloadRoot = p.join(root.path, 'dl'); + c.logFile = p.join(logs, 'app.log'); + + // 지울 것 + make(p.join(tmp, 'ai_123', 'audio.wav'), 5000); + make(p.join(tmp, 'jj_adjust_after_1.png')); + make(p.join(data, 'settings.json.4567.tmp')); + make(p.join(c.settings.ytDlpDir, '영상 [abc].mp4.part'), 3000); + make(p.join(c.settings.ytDlpDir, '영상 [abc].f137.mp4'), 2000); + make(p.join(c.settings.ytDlpDir, '영상 [abc].mp4.ytdl')); + make(p.join(c.settings.ytDlpDir, '재생목록', '두번째 [def].webm.part-Frag12')); + make(p.join(c.settings.aria2Dir, 'movie.mkv.aria2')); + make(p.join(models, 'whisper', 'ggml-small.bin.part'), 4000); + make(p.join(sysTmp, 'jj_mkvmaker_update_2026.10.04_001', 'JJ_MKVMaker.zip'), 6000); + make(p.join(sysTmp, 'jj_ffmpeg.download')); + make(p.join(logs, 'app.log.1'), 700); + + // 남길 것 + make(p.join(c.settings.ytDlpDir, '영상 [abc].mp4'), 9000); + make(p.join(c.settings.ytDlpDir, 'jj_mkv', '영상 [abc].mkv')); + make(p.join(c.settings.aria2Dir, 'movie.mkv')); + make(p.join(models, 'whisper', 'ggml-base.bin')); + make(p.join(data, 'settings.json')); + make(p.join(sysTmp, 'other_app.tmp')); + make(p.join(logs, 'app.log')); + }); + tearDown(() => root.deleteSync(recursive: true)); + + test('찾기: 묶음별로 남은 조각만 찾고, 받은 동영상 · MKV · 모델 · 설정 · 지금 기록은 건드리지 않는다', () async { + final groups = await Cleaner(c, dataDir: data, systemTemp: sysTmp).scan(); + Map> names = { + for (final g in groups) g.id: [for (final i in g.items) p.basename(i.path)]..sort(), + }; + expect(names['work'], ['ai_123', 'jj_adjust_after_1.png', 'settings.json.4567.tmp']); + expect(names['download'], [ + 'movie.mkv.aria2', + '두번째 [def].webm.part-Frag12', + '영상 [abc].f137.mp4', + '영상 [abc].mp4.part', + '영상 [abc].mp4.ytdl', + ]); + expect(names['models'], ['ggml-small.bin.part']); + expect(names['update'], ['jj_ffmpeg.download', 'jj_mkvmaker_update_2026.10.04_001']); + expect(names['logs'], ['app.log.1']); + expect(groups.firstWhere((g) => g.id == 'work').bytes, 5000 + 100 + 100); + expect(groups.every((g) => g.skipped == null), isTrue); + + final (n, bytes) = await Cleaner(c, dataDir: data, systemTemp: sysTmp).clean(groups); + expect(n, 3 + 5 + 1 + 2 + 1); + expect(bytes, greaterThan(20000)); + // 남길 것은 그대로 + for (final keep in [ + p.join(c.settings.ytDlpDir, '영상 [abc].mp4'), + p.join(c.settings.ytDlpDir, 'jj_mkv', '영상 [abc].mkv'), + p.join(c.settings.aria2Dir, 'movie.mkv'), + p.join(models, 'whisper', 'ggml-base.bin'), + p.join(data, 'settings.json'), + p.join(sysTmp, 'other_app.tmp'), + p.join(logs, 'app.log'), + ]) { + expect(File(keep).existsSync(), isTrue, reason: keep); + } + // 다시 찾으면 없음 + final again = await Cleaner(c, dataDir: data, systemTemp: sysTmp).scan(); + expect(again.every((g) => g.items.isEmpty), isTrue); + }); + + test('다운로드 중 (일시정지 포함) 이면 받다 만 다운로드는 건너뛴다', () async { + final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null); + addTearDown(d.dispose); + d.addPage('https://www.youtube.com/watch?v=abc'); + await Future.delayed(Duration.zero); + expect(d.activeCount, 1); + final groups = await Cleaner(c, downloads: d, dataDir: data, systemTemp: sysTmp).scan(); + final dl = groups.firstWhere((g) => g.id == 'download'); + expect(dl.skipped, isNotNull); + await Cleaner(c, downloads: d, dataDir: data, systemTemp: sysTmp).clean(groups); + expect(File(p.join(c.settings.ytDlpDir, '영상 [abc].mp4.part')).existsSync(), isTrue, reason: '이어받기용 조각은 그대로'); + expect(Directory(p.join(tmp, 'ai_123')).existsSync(), isFalse, reason: '다른 묶음은 정리'); + }); + + test('크기 글', () { + expect(Cleaner.sizeText(512), '512 B'); + expect(Cleaner.sizeText(2048), '2 KB'); + expect(Cleaner.sizeText(5 * 1024 * 1024), '5.0 MB'); + expect(Cleaner.sizeText(3 * 1024 * 1024 * 1024), '3.0 GB'); + }); +}