diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 40b05a7..cd578bd 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -65,7 +65,10 @@ "AI 그림 저장 폴더": "Folder for AI images", "AI 그림: {0}": "AI images: {0}", "AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)", + "AI 모델 {0}개는 확인 (SHA-256) 이 맞지 않아 되살리지 않았습니다. 사본은 공용 폴더에 남겨 두었습니다: {1}": "{0} AI model file(s) failed the SHA-256 check and were not restored. The copies were left in the shared folder: {1}", "AI 모델 받는 중: {0}": "Downloading AI model: {0}", + "AI 모델을 복사해 두는 중": "Copying AI models", + "AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}": "Stopped because the AI models could not be copied (the app was not removed): {0}", "AI 번역": "AI translation", "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", "AI 엔진 ({0}) 도 함께 받습니다.": "The AI engine ({0}) will be downloaded too.", @@ -103,6 +106,7 @@ "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android cannot install a lower version over the current app. So:\n1. Save the v{0} install file and the current settings (including the video list and bookmarks) to Download/JJ_MKVMaker.\n2. Uninstall this app (Android confirmation).\n3. Tap the APK in Download/JJ_MKVMaker in a file manager to install it.\n4. Versions with this feature ask at startup whether to restore the saved settings. Older versions need to be set up again.\n* Subtitle API keys and passwords are not saved, so enter them again.", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Press [Install] on the Android install screen. Settings, downloaded files and AI models stay as they are.\n(The first time, you may need to turn on \"Allow from this source\")", + "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "On Android the app is removed before the older version is installed. Downloaded AI models, passwords, browser logins and more are lost (the next window shows what stays and what is lost).", "Android 자동 백업에서 되살린 설정으로 시작합니다": "Starting with settings restored from Android Auto Backup", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", @@ -263,6 +267,8 @@ "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)", "v{0} 받는 중": "Downloading v{0}", + "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} cannot restore copied models. They will be restored when you update again to a version with this feature.", + "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} cannot restore the saved settings, so you will need to set things up again.", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", "yt-dlp 를 실행할 수 없습니다: {0}": "Cannot run yt-dlp: {0}", @@ -341,7 +347,10 @@ "{0}줄": "{0} lines", "{0}초": "{0} s", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", + "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· Downloaded AI models (about {0}, must be downloaded again)", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· Background: only hides the window and keeps downloading · converting (reopen with {0} or the tray icon)\n· Quit all: stops downloads · conversions and closes the program", + "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· Passwords · API keys · master and super passwords", + "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· In-app browser logins · cookies, activity log", "… 외 {0}개": "… and {0} more", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)", @@ -410,6 +419,7 @@ "고화질": "High quality", "골라서 시작": "Choose and start", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", + "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "관리 메뉴": "Manage menu", "광고 건너뛰는 중…": "Skipping ad…", "권한 허용 화면 열기": "Open the permission screen", @@ -481,7 +491,9 @@ "나머지는 지웠습니다.": "The rest were deleted.", "나중에": "Later", "날짜": "Date", + "남는 것": "What stays", "남은 {0}장": "{0} left", + "남은 공간 {0}": "{0} free", "남은 시간 {0}": "{0} left", "남은 약 {0}": "about {0} left", "남은 용량": "free", @@ -647,6 +659,8 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)", "동의하고 받기": "Agree and download", "동작 중 · {0} 까지": "Running · until {0}", + "되돌리기 계속": "Continue rollback", + "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "Rollback: copied AI models ({0}): {1}", "되돌릴 수 없습니다.": "This can't be undone.", "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", @@ -673,6 +687,7 @@ "로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "Opening WebDAV videos that need a login in another app", "로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit", "루마니아어": "Romanian", + "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", "마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 정하기": "Set master password", @@ -788,6 +803,9 @@ "받다 만 AI 모델": "Unfinished AI models", "받다 만 다운로드": "Unfinished downloads", "받아서 열기": "Download and open", + "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "Downloaded AI models ({0}) - Download/JJ_MKVMaker/AI 모델 보관", + "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "Copy downloaded AI models ({0}) to Download/JJ_MKVMaker and restore them after reinstalling (needs {0} more space for a while)", + "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "Downloaded AI models (images · subtitles) - must be downloaded again (about {0})", "받은 동영상을 편집 목록에 추가: {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)", @@ -855,6 +873,9 @@ "복사하는 중": "Copying", "복사할": "copy", "복사할까요?": "copy?", + "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "Restored the copied AI models ({0})", + "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "Restoring the copied AI models ({0})", + "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "Could not restore the copied AI models (the copies remain in Download/JJ_MKVMaker/AI 모델 보관): {0}", "복사했습니다.": "copied.", "볼 그림이 없습니다.": "No images to show.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", @@ -869,6 +890,7 @@ "비교하지 못했습니다: {0}": "Couldn't compare: {0}", "비밀번호": "Password", "비밀번호 (선택)": "Password (optional)", + "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", @@ -886,6 +908,7 @@ "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", "빼고 싶은 것 (선택)": "What to avoid (optional)", + "사라지는 것": "What is lost", "사용 안 함": "Off", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears", "사용: {0}": "Using: {0}", @@ -948,6 +971,7 @@ "선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "Play selected files: the video you are viewing (check several in the list)", "선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "Play selected files: the {0} checked videos in list order", "선택한 파일 재생{0}": "Play selected files{0}", + "설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "Settings (including the WebDAV server list, without passwords) · video list · bookmarks - Download/JJ_MKVMaker/설정 보관", "설정 되살리기": "Restore settings", "설정 반영 후 실행": "Apply and run", "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "Could not read the settings file; restored from backup (kept: {0}): {1}", @@ -1068,6 +1092,7 @@ "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)", "앱 아이콘": "App icon", "앱 안 브라우저 (권장)": "In-app browser (recommended)", + "앱 안 브라우저의 로그인 · 쿠키": "In-app browser logins · cookies", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)", "앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "In-app browser login cookies: besides YouTube and Google, Instagram, X and Naver (Chzzk) logins are now also used for downloads (yt-dlp). To choose, turn off or delete: Settings > Downloads > YouTube cookies", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "Deletes the in-app browser's cookies (logins), site data, cache and history, plus the cookie file exported for downloads. You'll need to log in to sites again. Bookmarks are kept.", @@ -1078,6 +1103,7 @@ "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}", "앱을 다시 켤 때 동기화": "Sync when the app restarts", "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "The app was newly installed and has no settings. Restore the saved settings of v{0} (including the video list and bookmarks)?\n(Subtitle API keys and passwords were not saved - please enter them again)", + "앱을 지우면 다음은 되살릴 수 없습니다:": "Removing the app loses the following for good:", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", "어둡게": "Darker", @@ -1388,6 +1414,7 @@ "자세히 닫기": "Hide details", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "For a home NAS with a self-signed certificate, etc. (trusted servers only)", "작업": "Jobs", + "작업 기록 · 받다 만 파일": "Activity log · unfinished downloads", "작업 기록 보기": "Show job log", "작업 기록 숨기기": "Hide job log", "작업 임시 파일": "Job temporary files", @@ -1462,6 +1489,7 @@ "정리할 파일을 찾는 중…": "Looking for files to clean up…", "정리할 파일이 없었습니다.": "There was nothing to clean up.", "정리했습니다: 파일 {0}개 · {1}": "Cleaned up: {0} files · {1}", + "정말 되돌릴까요?": "Really roll back?", "정말 초기화할까요?": "Really reset?", "정보": "Info", "정사각 1:1": "Square 1:1", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2d1f128..c580730 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -65,7 +65,10 @@ "AI 그림 저장 폴더": "AI 画像の保存フォルダー", "AI 그림: {0}": "AI 画像: {0}", "AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)", + "AI 모델 {0}개는 확인 (SHA-256) 이 맞지 않아 되살리지 않았습니다. 사본은 공용 폴더에 남겨 두었습니다: {1}": "AI モデル {0} 件は確認 (SHA-256) が一致せず復元しませんでした。コピーは共有フォルダーに残しました: {1}", "AI 모델 받는 중: {0}": "AI モデルをダウンロード中: {0}", + "AI 모델을 복사해 두는 중": "AI モデルをコピー中", + "AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}": "AI モデルをコピーできなかったため中止しました (アプリは削除していません): {0}", "AI 번역": "AI 翻訳", "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", "AI 엔진 ({0}) 도 함께 받습니다.": "AI エンジン ({0}) も一緒にダウンロードします。", @@ -103,6 +106,7 @@ "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android はバージョンの低いアプリを今のアプリの上にインストールできません。そこで:\n1. v{0} のインストールファイルと今の設定 (動画リスト · ブックマークを含む) を Download/JJ_MKVMaker に保存します。\n2. このアプリを削除します (Android の確認画面)。\n3. ファイルアプリで Download/JJ_MKVMaker の APK をタップしてインストールします。\n4. この機能があるバージョンなら起動時に保存した設定を復元するか尋ねます。それより古いバージョンは設定し直す必要があります。\n※ 字幕 API キー · パスワードは保存しないので再入力が必要です。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)", + "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "Android ではアプリを削除してから古いバージョンをインストールします。ダウンロードした AI モデル・パスワード・ブラウザーのログインなどは消えます (残るもの・消えるものは次の画面で表示します)。", "Android 자동 백업에서 되살린 설정으로 시작합니다": "Android 自動バックアップから復元した設定で起動します", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", @@ -263,6 +267,8 @@ "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)", "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows・NVIDIA CUDA 12)", "v{0} 받는 중": "v{0} をダウンロード中", + "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} はコピーしたモデルを復元できません。この機能がある版に戻すとその時に復元します。", + "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} は保存した設定を復元できないため、設定し直す必要があります。", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", "yt-dlp 를 실행할 수 없습니다: {0}": "yt-dlp を実行できません: {0}", @@ -341,7 +347,10 @@ "{0}줄": "{0}行", "{0}초": "{0}秒", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", + "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· ダウンロードした AI モデル (約 {0}、再ダウンロードが必要)", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", + "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· パスワード・API キー・マスター / 最上位パスワード", + "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· アプリ内ブラウザーのログイン・Cookie、作業記録", "… 외 {0}개": "… ほか {0} 件", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", @@ -410,6 +419,7 @@ "고화질": "高画質", "골라서 시작": "選んで開始", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", + "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "관리 메뉴": "管理メニュー", "광고 건너뛰는 중…": "広告をスキップ中…", "권한 허용 화면 열기": "権限の許可画面を開く", @@ -481,7 +491,9 @@ "나머지는 지웠습니다.": "残りは削除しました。", "나중에": "後で", "날짜": "日付", + "남는 것": "残るもの", "남은 {0}장": "残り {0} 枚", + "남은 공간 {0}": "空き容量 {0}", "남은 시간 {0}": "残り時間 {0}", "남은 약 {0}": "残り約 {0}", "남은 용량": "空き容量", @@ -647,6 +659,8 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)", "동의하고 받기": "同意してダウンロード", "동작 중 · {0} 까지": "動作中 · {0} まで", + "되돌리기 계속": "戻す操作を続ける", + "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "戻す: AI モデル {0} をコピーしました: {1}", "되돌릴 수 없습니다.": "元に戻せません。", "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", @@ -673,6 +687,7 @@ "로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "ログインが必要な WebDAV 動画を他のアプリで開くとき", "로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます", "루마니아어": "ルーマニア語", + "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", "마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 정하기": "マスターパスワードを設定", @@ -788,6 +803,9 @@ "받다 만 AI 모델": "ダウンロード途中の AI モデル", "받다 만 다운로드": "ダウンロード途中のファイル", "받아서 열기": "ダウンロードして開く", + "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "ダウンロードした AI モデル ({0}) - Download/JJ_MKVMaker/AI 모델 보관", + "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "ダウンロードした AI モデル ({0}) を Download/JJ_MKVMaker にコピーし、再インストール後に復元する (一時的に {0} 余分に必要)", + "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "ダウンロードした AI モデル (画像・字幕) - 再ダウンロードが必要です (約 {0})", "받은 동영상을 편집 목록에 추가: {0}": "受け取った動画を編集リストに追加: {0}", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)", @@ -855,6 +873,9 @@ "복사하는 중": "コピー中", "복사할": "コピーする", "복사할까요?": "コピーしますか?", + "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "コピーした AI モデル ({0}) を復元しました", + "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "コピーした AI モデル ({0}) を復元中", + "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "コピーした AI モデルを復元できませんでした (コピーは Download/JJ_MKVMaker/AI 모델 보관 にそのまま): {0}", "복사했습니다.": "コピーしました。", "볼 그림이 없습니다.": "表示する画像がありません。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", @@ -869,6 +890,7 @@ "비교하지 못했습니다: {0}": "比較できませんでした: {0}", "비밀번호": "パスワード", "비밀번호 (선택)": "パスワード(任意)", + "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", @@ -886,6 +908,7 @@ "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", "빼고 싶은 것 (선택)": "避けたいもの (任意)", + "사라지는 것": "消えるもの", "사용 안 함": "使用しない", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください", "사용: {0}": "使用: {0}", @@ -948,6 +971,7 @@ "선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "選択したファイルを再生: 今見ている動画(複数はリストでチェック)", "선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "選択したファイルを再生: チェックした動画 {0}個をリスト順に続けて", "선택한 파일 재생{0}": "選択したファイルを再生{0}", + "설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "設定 (WebDAV サーバー一覧を含む、パスワードを除く)・動画リスト・お気に入り - Download/JJ_MKVMaker/설정 보관", "설정 되살리기": "設定の復元", "설정 반영 후 실행": "反映して実行", "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "設定ファイルを読めずバックアップから復元 (保管: {0}): {1}", @@ -1068,6 +1092,7 @@ "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)", "앱 아이콘": "アプリアイコン", "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", + "앱 안 브라우저의 로그인 · 쿠키": "アプリ内ブラウザーのログイン・Cookie", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)", "앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "アプリ内ブラウザのログイン Cookie: YouTube・Google に加えて Instagram・X・NAVER (CHZZK) のログインもダウンロード (yt-dlp) に使います。選択・オフ・削除: 環境設定 > ダウンロード > YouTube Cookie", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。", @@ -1078,6 +1103,7 @@ "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}", "앱을 다시 켤 때 동기화": "アプリ再起動時の同期", "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "アプリを新しくインストールしたため設定がありません。保存した v{0} の設定 (動画リスト · ブックマークを含む) を復元しますか?\n(字幕 API キー · パスワードは保存していないので再入力してください)", + "앱을 지우면 다음은 되살릴 수 없습니다:": "アプリを削除すると次のものは復元できません:", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "어둡게": "暗く", @@ -1388,6 +1414,7 @@ "자세히 닫기": "詳細を閉じる", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "自己署名証明書を使う自宅 NAS など (信頼できるサーバーのみ)", "작업": "作業", + "작업 기록 · 받다 만 파일": "作業記録・ダウンロード途中のファイル", "작업 기록 보기": "作業記録を表示", "작업 기록 숨기기": "作業記録を隠す", "작업 임시 파일": "作業の一時ファイル", @@ -1462,6 +1489,7 @@ "정리할 파일을 찾는 중…": "整理するファイルを探しています…", "정리할 파일이 없었습니다.": "整理するファイルはありませんでした。", "정리했습니다: 파일 {0}개 · {1}": "整理しました: ファイル {0}個・{1}", + "정말 되돌릴까요?": "本当に戻しますか?", "정말 초기화할까요?": "本当に初期化しますか?", "정보": "情報", "정사각 1:1": "正方形 1:1", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 08808e3..396ec6b 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -65,7 +65,10 @@ "AI 그림 저장 폴더": "AI 图片保存文件夹", "AI 그림: {0}": "AI 图片: {0}", "AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)", + "AI 모델 {0}개는 확인 (SHA-256) 이 맞지 않아 되살리지 않았습니다. 사본은 공용 폴더에 남겨 두었습니다: {1}": "{0} 个 AI 模型文件校验 (SHA-256) 不一致,未恢复。副本保留在公共文件夹: {1}", "AI 모델 받는 중: {0}": "正在下载 AI 模型: {0}", + "AI 모델을 복사해 두는 중": "正在复制 AI 模型", + "AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}": "无法复制 AI 模型,已停止 (未删除应用): {0}", "AI 번역": "AI 翻译", "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", "AI 엔진 ({0}) 도 함께 받습니다.": "也会一并下载 AI 引擎 ({0})。", @@ -103,6 +106,7 @@ "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android 无法在当前应用上安装版本更低的应用。因此:\n1. 将 v{0} 安装文件和当前设置 (包括视频列表 · 书签) 保存到 Download/JJ_MKVMaker。\n2. 卸载此应用 (Android 确认窗口)。\n3. 在文件应用中点按 Download/JJ_MKVMaker 中的 APK 进行安装。\n4. 带有此功能的版本会在启动时询问是否恢复保存的设置。更早的版本需要重新设置。\n※ 字幕 API 密钥 · 密码不会保存,需要重新输入。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)", + "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "在 Android 上会先删除应用再安装旧版本。已下载的 AI 模型、密码、浏览器登录等将丢失 (下一个窗口会列出保留和丢失的内容)。", "Android 자동 백업에서 되살린 설정으로 시작합니다": "使用从 Android 自动备份恢复的设置启动", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", @@ -263,6 +267,8 @@ "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)": "stable-diffusion.cpp (Windows · NVIDIA CUDA 12)", "v{0} 받는 중": "正在下载 v{0}", + "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} 无法恢复已复制的模型。再次升级到带有此功能的版本时会恢复。", + "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} 无法恢复已保存的设置,需要重新设置。", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", "yt-dlp 를 실행할 수 없습니다: {0}": "无法运行 yt-dlp: {0}", @@ -341,7 +347,10 @@ "{0}줄": "{0} 行", "{0}초": "{0} 秒", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", + "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· 已下载的 AI 模型 (约 {0},需要重新下载)", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", + "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· 密码 · API 密钥 · 主密码和最高密码", + "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· 应用内浏览器的登录 · Cookie、操作记录", "… 외 {0}개": "… 另外 {0} 个", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", @@ -410,6 +419,7 @@ "고화질": "高画质", "골라서 시작": "选择后开始", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", + "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "관리 메뉴": "管理菜单", "광고 건너뛰는 중…": "正在跳过广告…", "권한 허용 화면 열기": "打开权限授予界面", @@ -481,7 +491,9 @@ "나머지는 지웠습니다.": "其余已删除。", "나중에": "以后再说", "날짜": "日期", + "남는 것": "保留的内容", "남은 {0}장": "剩余 {0} 张", + "남은 공간 {0}": "剩余空间 {0}", "남은 시간 {0}": "剩余时间 {0}", "남은 약 {0}": "剩余约 {0}", "남은 용량": "剩余空间", @@ -647,6 +659,8 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)", "동의하고 받기": "同意并下载", "동작 중 · {0} 까지": "运行中 · 直到 {0}", + "되돌리기 계속": "继续回退", + "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "回退: 已复制 AI 模型 {0}: {1}", "되돌릴 수 없습니다.": "无法撤销。", "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", @@ -673,6 +687,7 @@ "로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때": "用其他应用打开需要登录的 WebDAV 视频时", "로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加", "루마니아어": "罗马尼亚语", + "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", "마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 정하기": "设置主密码", @@ -788,6 +803,9 @@ "받다 만 AI 모델": "未下载完的 AI 模型", "받다 만 다운로드": "未完成的下载", "받아서 열기": "下载后打开", + "받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관": "已下载的 AI 模型 ({0}) - Download/JJ_MKVMaker/AI 모델 보관", + "받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)": "将已下载的 AI 模型 ({0}) 复制到 Download/JJ_MKVMaker,重新安装后恢复 (暂时需要额外 {0})", + "받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})": "已下载的 AI 模型 (图片 · 字幕) - 需要重新下载 (约 {0})", "받은 동영상을 편집 목록에 추가: {0}": "已将收到的视频加入编辑列表: {0}", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)", @@ -855,6 +873,9 @@ "복사하는 중": "正在复制", "복사할": "复制", "복사할까요?": "复制吗?", + "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "已恢复复制的 AI 模型 ({0})", + "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "正在恢复复制的 AI 模型 ({0})", + "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "无法恢复复制的 AI 模型 (副本仍在 Download/JJ_MKVMaker/AI 모델 보관): {0}", "복사했습니다.": "已复制。", "볼 그림이 없습니다.": "没有可显示的图片。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", @@ -869,6 +890,7 @@ "비교하지 못했습니다: {0}": "无法比较: {0}", "비밀번호": "密码", "비밀번호 (선택)": "密码(可选)", + "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", @@ -886,6 +908,7 @@ "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", "빼고 싶은 것 (선택)": "要排除的内容 (可选)", + "사라지는 것": "丢失的内容", "사용 안 함": "不使用", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置", "사용: {0}": "使用: {0}", @@ -948,6 +971,7 @@ "선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "播放所选文件: 当前查看的视频(多个请在列表中勾选)", "선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "播放所选文件: 按列表顺序连续播放勾选的 {0} 个视频", "선택한 파일 재생{0}": "播放所选文件{0}", + "설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관": "设置 (含 WebDAV 服务器列表,不含密码) · 视频列表 · 收藏 - Download/JJ_MKVMaker/설정 보관", "설정 되살리기": "恢复设置", "설정 반영 후 실행": "应用后运行", "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "无法读取设置文件,已从备份恢复 (保存于: {0}): {1}", @@ -1068,6 +1092,7 @@ "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)", "앱 아이콘": "应用图标", "앱 안 브라우저 (권장)": "应用内浏览器(推荐)", + "앱 안 브라우저의 로그인 · 쿠키": "应用内浏览器的登录 · Cookie", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)", "앱 안 브라우저의 로그인 쿠키: YouTube · Google 에 더해 Instagram · X · 네이버 (치지직) 로그인도 다운로드 (yt-dlp) 에 씁니다. 고르기 · 끄기 · 지우기: 환경 설정 > 다운로드 > YouTube 쿠키": "应用内浏览器的登录 Cookie: 除 YouTube · Google 外,Instagram · X · Naver (CHZZK) 的登录也会用于下载 (yt-dlp)。选择 · 关闭 · 删除: 设置 > 下载 > YouTube Cookie", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。", @@ -1078,6 +1103,7 @@ "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}", "앱을 다시 켤 때 동기화": "应用重新启动时同步", "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "应用为全新安装,没有设置。要恢复保存的 v{0} 设置 (包括视频列表 · 书签) 吗?\n(字幕 API 密钥 · 密码未保存,请重新输入)", + "앱을 지우면 다음은 되살릴 수 없습니다:": "删除应用后以下内容无法恢复:", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "어둡게": "调暗", @@ -1388,6 +1414,7 @@ "자세히 닫기": "收起详情", "자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "用于使用自签名证书的家用 NAS 等 (仅限可信服务器)", "작업": "任务", + "작업 기록 · 받다 만 파일": "操作记录 · 未下载完的文件", "작업 기록 보기": "显示任务记录", "작업 기록 숨기기": "隐藏任务记录", "작업 임시 파일": "任务临时文件", @@ -1462,6 +1489,7 @@ "정리할 파일을 찾는 중…": "正在查找可清理的文件…", "정리할 파일이 없었습니다.": "没有可清理的文件。", "정리했습니다: 파일 {0}개 · {1}": "已清理: 文件 {0} 个 · {1}", + "정말 되돌릴까요?": "确定要回退吗?", "정말 초기화할까요?": "确定要重置吗?", "정보": "信息", "정사각 1:1": "正方形 1:1", diff --git a/lib/app/model_backup.dart b/lib/app/model_backup.dart new file mode 100644 index 0000000..d74694e --- /dev/null +++ b/lib/app/model_backup.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import 'component_store.dart' show AccumulatorSink; + +/// P0 (되돌리기): Android 에서 예전 버전으로 되돌리면 앱을 지워야 해서 앱 안에 받은 AI 모델이 사라진다. +/// 공용 폴더 (Download/JJ_MKVMaker/AI 모델 보관) 에 옮겨 두었다가, 다시 설치한 앱 (이 기능이 있는 판) 이 켜질 때 +/// SHA-256 을 확인하며 되살리고 공용 폴더의 사본은 지운다. +/// +/// [roots]: 보관할 폴더들 (이름 → 경로, 예: 'ai' → 앱 데이터/ai, 'models' → 자막 모델 폴더) +class ModelBackup { + ModelBackup(this.dir); + + /// 보관 폴더 (Download/JJ_MKVMaker/AI 모델 보관) + final String dir; + + /// 공용 폴더 Download/JJ_MKVMaker (Android, main 에서 정함) + static String? sharedRoot; + + static ModelBackup? get shared => sharedRoot == null ? null : ModelBackup(p.join(sharedRoot!, 'AI 모델 보관')); + + File get _manifest => File(p.join(dir, 'manifest.json')); + + /// 되살릴 보관본이 있는지 + bool get exists => _manifest.existsSync(); + + /// 받다 만 조각 · 임시 파일은 보관하지 않는다 + static bool _skip(String path) { + final n = p.basename(path).toLowerCase(); + return n.endsWith('.part') || n.endsWith('.jjpart') || n.endsWith('.tmp'); + } + + static Iterable<(String root, String rel, File f)> _files(Map roots) sync* { + for (final e in roots.entries) { + final d = Directory(e.value); + if (!d.existsSync()) continue; + for (final f in d.listSync(recursive: true, followLinks: false).whereType()) { + if (_skip(f.path)) continue; + yield (e.key, p.relative(f.path, from: e.value), f); + } + } + } + + /// 보관할 크기 (바이트) + static int sizeOf(Map roots) { + var n = 0; + for (final (_, _, f) in _files(roots)) { + try { + n += f.lengthSync(); + } catch (_) {} + } + return n; + } + + /// 파일을 복사하며 SHA-256 을 잰다 + static Future _copyHashed(File from, File to, void Function(int n)? onBytes) async { + await to.parent.create(recursive: true); + final out = to.openWrite(); + final sink = AccumulatorSink(); + final hash = sha256.startChunkedConversion(sink); + try { + await for (final chunk in from.openRead()) { + out.add(chunk); + hash.add(chunk); + onBytes?.call(chunk.length); + } + } finally { + await out.close(); + } + hash.close(); + return sink.events.single.toString(); + } + + /// [roots] 의 파일을 보관 폴더로 복사하고 목록 (경로 · 크기 · SHA-256) 을 남긴다. 실패하면 만든 것을 지우고 다시 던진다. + Future save(Map roots, {void Function(int done, int total)? onProgress}) async { + final d = Directory(dir); + if (d.existsSync()) await d.delete(recursive: true); + final total = sizeOf(roots); + var done = 0; + final list = >[]; + try { + for (final (root, rel, f) in _files(roots).toList()) { + final sha = await _copyHashed(f, File(p.join(dir, root, rel)), (n) { + done += n; + onProgress?.call(done, total); + }); + list.add({'root': root, 'rel': p.posix.joinAll(p.split(rel)), 'size': f.lengthSync(), 'sha256': sha}); + } + // 목록은 마지막에 (목록이 있으면 다 복사된 것) + await _manifest.writeAsString(jsonEncode({'saved': DateTime.now().toIso8601String(), 'files': list})); + } catch (_) { + try { + await d.delete(recursive: true); + } catch (_) {} + rethrow; + } + return total; + } + + /// 보관한 크기 (목록 기준) + int get savedSize { + try { + final j = jsonDecode(_manifest.readAsStringSync()) as Map; + return (j['files'] as List).fold(0, (a, e) => a + ((e as Map)['size'] as num).toInt()); + } catch (_) { + return 0; + } + } + + /// 되살린다: 이미 같은 크기로 있으면 건너뛰고, 복사한 것은 SHA-256 을 목록과 맞춰 본다. + /// 모두 되살렸으면 보관 폴더를 지운다. 맞지 않거나 실패한 파일은 [RestoreResult.failed] (보관본은 남김). + Future restore(Map roots, {void Function(int done, int total)? onProgress}) async { + final j = jsonDecode(await _manifest.readAsString()) as Map; + final files = [for (final e in j['files'] as List) Map.from(e as Map)]; + final total = files.fold(0, (a, e) => a + (e['size'] as num).toInt()); + var done = 0, restored = 0, skipped = 0; + final failed = []; + for (final e in files) { + final base = roots['${e['root']}']; + final rel = '${e['rel']}'; + final size = (e['size'] as num).toInt(); + if (base == null) { + failed.add(rel); + continue; + } + final target = File(p.join(base, p.joinAll(p.posix.split(rel)))); + if (target.existsSync() && target.lengthSync() == size) { + skipped++; + done += size; + onProgress?.call(done, total); + continue; + } + final tmp = File('${target.path}.jjpart'); + try { + final sha = await _copyHashed(File(p.join(dir, '${e['root']}', p.joinAll(p.posix.split(rel)))), tmp, (n) { + done += n; + onProgress?.call(done, total); + }); + if (sha != e['sha256']) { + await tmp.delete(); + failed.add(rel); + continue; + } + if (target.existsSync()) await target.delete(); + await tmp.rename(target.path); + restored++; + } catch (_) { + try { + if (tmp.existsSync()) await tmp.delete(); + } catch (_) {} + failed.add(rel); + } + } + if (failed.isEmpty) { + try { + await Directory(dir).delete(recursive: true); + } catch (_) {} + } + return RestoreResult(restored, skipped, failed); + } +} + +class RestoreResult { + const RestoreResult(this.restored, this.skipped, this.failed); + final int restored; + final int skipped; + final List failed; +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 622d6a5..f19c907 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -538,6 +538,9 @@ class AppSettings { /// 돌아온 버전이 자기 것이 아니면 보관해 둔 그 버전의 설정을 되살릴지 묻는다 (VersionSnapshot) String lastRunVersion = ''; + /// P0 (Android 되돌리기): 받은 AI 모델을 공용 폴더에 옮겨 두었다가 되살릴지. '' = 남은 공간이 모델의 2배 이상이면 켬 · 'on' · 'off' + String rollbackKeepModels = ''; + /// "이 버전 건너뛰기" 한 버전 String skippedVersion = ''; @@ -700,6 +703,7 @@ class AppSettings { 'lastUpdateCheck': lastUpdateCheck, 'lastUpdateCheckVersion': lastUpdateCheckVersion, 'lastRunVersion': lastRunVersion, + 'rollbackKeepModels': rollbackKeepModels, 'skippedVersion': skippedVersion, 'playlistMode': playlistMode.name, 'externalPlayers': externalPlayers, @@ -900,6 +904,7 @@ class AppSettings { ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? '' ..lastRunVersion = j['lastRunVersion'] as String? ?? '' + ..rollbackKeepModels = const ['on', 'off'].contains(j['rollbackKeepModels']) ? j['rollbackKeepModels'] as String : '' ..skippedVersion = j['skippedVersion'] as String? ?? '' ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast() diff --git a/lib/main.dart b/lib/main.dart index b6ca6c8..d530624 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart'; import 'app/app_controller.dart'; import 'app/live_sync.dart'; +import 'app/model_backup.dart'; import 'app/version_snapshot.dart'; import 'app/install_marker.dart'; import 'app/component_store.dart'; @@ -477,6 +478,7 @@ Future runAndroid(String dataDir) async { try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관'); + if (root != null) ModelBackup.sharedRoot = p.join(root, 'Download', 'JJ_MKVMaker'); } catch (_) {} VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots); await controller.init(); @@ -599,6 +601,9 @@ Future runAndroid(String dataDir) async { await services.shell.quit(); }); } + // P0: 되돌리느라 공용 폴더에 옮겨 둔 받은 AI 모델 되살리기 + final ctxModels = navigatorKey.currentContext; + if (ctxModels != null && ctxModels.mounted) await restoreModelBackup(ctxModels, controller); // 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음) final ctx = navigatorKey.currentContext; if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller)); diff --git a/lib/ui/update_dialog.dart b/lib/ui/update_dialog.dart index 93a5366..136879a 100644 --- a/lib/ui/update_dialog.dart +++ b/lib/ui/update_dialog.dart @@ -3,7 +3,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; +import '../app/ai_local.dart' show AiStore; import '../app/app_controller.dart'; +import '../app/copy_center.dart' show diskSpace; +import '../app/model_backup.dart'; import '../app/version_snapshot.dart'; import '../core/app_update.dart'; import '../services/updater.dart' show InstallPermissionNeeded, Updater; @@ -202,8 +205,14 @@ class _VersionPickerState extends State<_VersionPicker> { Padding( padding: const EdgeInsets.only(top: 6), child: Text( - tr('예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, ' - '나중에 이 버전으로 다시 오면 되살릴 수 있습니다.'), + [ + tr('예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, ' + '나중에 이 버전으로 다시 오면 되살릴 수 있습니다.'), + // P0: Android 는 앱을 지운 뒤 설치한다 - 무엇이 사라지는지는 다음 창에서 + if (Platform.isAndroid) + tr('Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 ' + '사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).'), + ].join('\n'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), const SizedBox(height: 8), @@ -386,6 +395,48 @@ Future _saveSnapshot(AppController c, String current) async { } } +/// P0: 되돌리기 때 옮겨 둘 받은 AI 모델 폴더 (그림 · 해상도: 앱 데이터/ai, 자막: 모델 폴더) +Future> rollbackModelRoots(AppController c) async => { + if (AiStore.instance != null) 'ai': AiStore.instance!.root, + 'models': await c.services.models.directory(), + }; + +/// 크기를 GB · MB 로 +String gbText(int bytes) => bytes >= 1 << 30 + ? '${(bytes / (1 << 30)).toStringAsFixed(1)}GB' + : '${(bytes / (1 << 20)).toStringAsFixed(0)}MB'; + +/// 진행 창을 띄우고 [work] 를 한다 (끌 수 없음). 실패하면 그 글 +Future withProgressDialog( + BuildContext context, String title, Future Function(void Function(int done, int total) onProgress) work) async { + final progress = ValueNotifier(null); + final nav = Navigator.of(context); + // ignore: unawaited_futures + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(title), + content: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + LinearProgressIndicator(value: v), + const SizedBox(height: 6), + Text(v == null ? tr('준비하는 중') : '${(v * 100).floor()}%'), + ]), + ), + ), + ); + String? error; + try { + await work((done, total) => progress.value = total == 0 ? null : done / total); + } catch (e) { + error = '$e'; + } + nav.pop(); + return error; +} + /// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다. /// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다. Future _androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async { @@ -395,29 +446,107 @@ Future _androidRollback(BuildContext context, AppController c, Updater up, ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.')))); return; } + // P0: 남는 것 / 사라지는 것을 사실대로. 받은 AI 모델은 공용 폴더에 옮겨 둘 수 있다 + final roots = await rollbackModelRoots(c); + final modelSize = ModelBackup.sizeOf(roots); + // 공용 폴더는 처음엔 없을 수 있다 - 있는 가장 가까운 상위 폴더로 남은 공간을 잰다 + var probe = shared; + while (!Directory(probe).existsSync() && p.dirname(probe) != probe) { + probe = p.dirname(probe); + } + final free = (await diskSpace(probe))?.$1; + final setting = c.settings.rollbackKeepModels; + var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2)); + // 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터) + final targetRestoresModels = compareVersions(r.version, '2026.10.09_001') > 0; + final targetRestoresSettings = compareVersions(r.version, '2026.10.08_001') >= 0; + if (!context.mounted) return; final ok = await showDialog( context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])), - content: SizedBox( - width: 560, - child: Text(trf( - 'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n' - '1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n' - '2. 이 앱을 지웁니다 (Android 확인 창).\n' - '3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n' - '4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n' - '※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.', - [r.version])), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))), - ], + builder: (ctx) => StatefulBuilder( + builder: (ctx, setInner) { + final size = gbText(modelSize); + final kept = [ + tr('설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관'), + tr('공용 폴더에 받은 파일 (동영상 · 그림 등)'), + if (keep) trf('받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관', [size]), + ]; + final lost = [ + if (modelSize > 0 && !keep) trf('받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})', [size]), + tr('비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)'), + tr('마스터 · 최상 비밀번호'), + tr('앱 안 브라우저의 로그인 · 쿠키'), + tr('작업 기록 · 받다 만 파일'), + ]; + return AlertDialog( + scrollable: true, + title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])), + content: SizedBox( + width: 560, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf( + 'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 "남는 것" 을 ' + 'Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.', + [r.version])), + const SizedBox(height: 10), + Text(tr('남는 것'), style: const TextStyle(fontWeight: FontWeight.w600)), + for (final x in kept) Text('· $x'), + const SizedBox(height: 8), + Text(tr('사라지는 것'), style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)), + for (final x in lost) Text('· $x', style: const TextStyle(color: Colors.orangeAccent)), + if (modelSize > 0) ...[ + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: keep, + onChanged: (v) { + setInner(() => keep = v ?? false); + c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off'); + }, + title: Text(trf('받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)', [size])), + subtitle: Text([ + if (free != null) trf('남은 공간 {0}', [gbText(free)]), + if (!targetRestoresModels) + trf('v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.', [r.version]), + ].join(' · ')), + ), + ], + if (!targetRestoresSettings) + Text(trf('v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.', [r.version]), + style: const TextStyle(color: Colors.orangeAccent)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))), + ], + ); + }, ), ); if (ok != true || !context.mounted) return; + // 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다) + final sure = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('정말 되돌릴까요?')), + content: Text([ + tr('앱을 지우면 다음은 되살릴 수 없습니다:'), + if (modelSize > 0 && !keep) trf('· 받은 AI 모델 (약 {0}, 다시 받아야 함)', [gbText(modelSize)]), + tr('· 비밀번호 · API 키 · 마스터 · 최상 비밀번호'), + tr('· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록'), + ].join('\n')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.redAccent), + onPressed: () => Navigator.pop(ctx, true), + child: Text(tr('되돌리기 계속')), + ), + ], + ), + ); + if (sure != true || !context.mounted) return; final apk = await _download(context, up, r); if (apk == null || !context.mounted) return; final outDir = p.dirname(shared); @@ -432,6 +561,20 @@ Future _androidRollback(BuildContext context, AppController c, Updater up, } return; } + // P0: 받은 AI 모델을 공용 폴더로 (실패하면 앱을 지우지 않는다) + if (keep && modelSize > 0) { + final backup = ModelBackup.shared; + if (backup == null || !context.mounted) return; + final err = await withProgressDialog(context, tr('AI 모델을 복사해 두는 중'), (onProgress) => backup.save(roots, onProgress: onProgress)); + if (err != null) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(trf('AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}', [err])))); + } + return; + } + c.note(trf('되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}', [gbText(modelSize), backup.dir])); + } if (!context.mounted) return; final go = await showDialog( context: context, diff --git a/lib/ui/version_restore.dart b/lib/ui/version_restore.dart index aeaaa64..8135320 100644 --- a/lib/ui/version_restore.dart +++ b/lib/ui/version_restore.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; +import '../app/model_backup.dart'; import '../app/version_snapshot.dart'; import '../l10n/tr.dart'; +import 'update_dialog.dart' show gbText, rollbackModelRoots, withProgressDialog; /// 시작할 때: 다른 버전을 쓰다가 이 버전으로 돌아왔으면 (또는 Android 에서 앱을 다시 설치해 설정이 없으면) /// 보관해 둔 설정을 되살릴지 묻는다. 되살리면 [restart] (다시 시작) 를 부른다. 끝나면 [lastRunVersion] 을 이 버전으로. @@ -120,3 +122,27 @@ Future showAutoBackupNotice(BuildContext context, ), ); } + +/// P0: 되돌리느라 공용 폴더에 옮겨 둔 받은 AI 모델이 있으면 되살린다 (SHA-256 확인, 다 되면 공용 폴더의 사본을 지움). +/// 이 기능이 있는 판이 켜질 때 (되돌린 뒤 다시 설치했거나, 예전 판에서 이 판으로 다시 올렸을 때) 한 번. +Future restoreModelBackup(BuildContext context, AppController c) async { + final backup = ModelBackup.shared; + if (backup == null || !backup.exists) return; + final roots = await rollbackModelRoots(c); + if (!context.mounted) return; + final size = gbText(backup.savedSize); + RestoreResult? result; + final err = await withProgressDialog(context, trf('복사해 둔 AI 모델 ({0}) 을 되살리는 중', [size]), (onProgress) async { + result = await backup.restore(roots, onProgress: onProgress); + return null; + }); + final r = result; + final text = err != null + ? trf('복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}', [err]) + : r!.failed.isEmpty + ? trf('복사해 둔 AI 모델 ({0}) 을 되살렸습니다', [size]) + : trf('AI 모델 {0}개는 확인 (SHA-256) 이 맞지 않아 되살리지 않았습니다. 사본은 공용 폴더에 남겨 두었습니다: {1}', + [r.failed.length, r.failed.take(3).join(', ')]); + c.note(text); + if (context.mounted) ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(text))); +} diff --git a/test/model_backup_test.dart b/test/model_backup_test.dart new file mode 100644 index 0000000..c2b7cf7 --- /dev/null +++ b/test/model_backup_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/model_backup.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + late Map roots; + late ModelBackup backup; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_mb_'); + roots = {'ai': p.join(tmp.path, 'data', 'ai'), 'models': p.join(tmp.path, 'data', 'models')}; + File(p.join(roots['ai']!, 'models', 'sd15.safetensors')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.generate(300000, (i) => i % 251)); + File(p.join(roots['ai']!, 'models', 'sd15.json')) + ..createSync(recursive: true) + ..writeAsStringSync('{"sha256":"x"}'); + File(p.join(roots['ai']!, 'models', 'half.safetensors.part')) + ..createSync(recursive: true) + ..writeAsStringSync('partial'); + File(p.join(roots['models']!, 'whisper', 'ggml-base.bin')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(1000, 7)); + backup = ModelBackup(p.join(tmp.path, 'Download', 'JJ_MKVMaker', 'AI 모델 보관')); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + void wipeAppData() => Directory(p.join(tmp.path, 'data')).deleteSync(recursive: true); + + test('P0: 받은 모델을 복사해 두고 (조각은 빼고) 앱을 지운 뒤 되살리면 같은 파일 · 사본은 지운다', () async { + final size = ModelBackup.sizeOf(roots); + expect(size, 300000 + 14 + 1000, reason: '.part 는 빼고'); + final progress = []; + await backup.save(roots, onProgress: (d, t) => progress.add(d)); + expect(progress.last, size); + expect(backup.exists, true); + expect(backup.savedSize, size); + final before = File(p.join(roots['ai']!, 'models', 'sd15.safetensors')).readAsBytesSync(); + wipeAppData(); // 앱을 지움 + final r = await backup.restore(roots); + expect((r.restored, r.skipped), (3, 0)); + expect(r.failed, isEmpty); + expect(File(p.join(roots['ai']!, 'models', 'sd15.safetensors')).readAsBytesSync(), before); + expect(File(p.join(roots['models']!, 'whisper', 'ggml-base.bin')).lengthSync(), 1000); + expect(File(p.join(roots['ai']!, 'models', 'half.safetensors.part')).existsSync(), false); + expect(Directory(backup.dir).existsSync(), false, reason: '다 되살리면 공용 폴더의 사본을 지운다'); + }); + + test('SHA-256 이 맞지 않는 사본은 되살리지 않고 보관본을 남긴다', () async { + await backup.save(roots); + wipeAppData(); + // 공용 폴더의 사본이 (다른 앱 · 사용자에 의해) 바뀜 + File(p.join(backup.dir, 'models', 'whisper', 'ggml-base.bin')).writeAsBytesSync(List.filled(1000, 8)); + final r = await backup.restore(roots); + expect(r.failed, ['whisper/ggml-base.bin']); + expect(File(p.join(roots['models']!, 'whisper', 'ggml-base.bin')).existsSync(), false); + expect(File(p.join(roots['models']!, 'whisper', 'ggml-base.bin.jjpart')).existsSync(), false); + expect(backup.exists, true, reason: '실패하면 사본을 남김'); + }); + + test('이미 같은 크기로 있는 파일은 건너뛴다 (예전 판이 다시 받은 경우)', () async { + await backup.save(roots); + final r = await backup.restore(roots); // 지우지 않고 바로 + expect((r.restored, r.skipped, r.failed.length), (0, 3, 0)); + }); + + test('목록이 없으면 (복사 도중 끊김) 되살릴 것이 없다', () async { + await backup.save(roots); + File(p.join(backup.dir, 'manifest.json')).deleteSync(); + expect(backup.exists, false); + }); + + test('설정: 기본은 자동 (남은 공간으로), 고른 값은 남는다', () { + expect(AppSettings().rollbackKeepModels, ''); + final b = AppSettings.fromJson((AppSettings()..rollbackKeepModels = 'off').toJson()); + expect(b.rollbackKeepModels, 'off'); + expect(AppSettings.fromJson({'rollbackKeepModels': 'weird'}).rollbackKeepModels, ''); + }); +} diff --git a/test/update_ui_test.dart b/test/update_ui_test.dart index 9d342f0..41ae948 100644 --- a/test/update_ui_test.dart +++ b/test/update_ui_test.dart @@ -3,11 +3,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_local.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/model_backup.dart'; import 'package:jj_mkvmaker/app/version_snapshot.dart'; import 'package:jj_mkvmaker/core/app_update.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/model_store.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/updater.dart'; import 'package:jj_mkvmaker/ui/update_dialog.dart'; @@ -71,6 +74,16 @@ class _FakeUpdater implements Updater { Future openPage(ReleaseInfo r) async => calls.add('page'); } +/// 실제 파일 작업 (크기 재기 · 남은 공간 · 복사) 이 끝날 때까지 +Future settleIo(WidgetTester tester, {required bool Function() until}) async { + // 위젯 시험 안의 파일 읽기 · 쓰기는 pump 마다 조금씩 나아간다 - 넉넉히 (끝나면 바로 나옴) + for (var i = 0; i < 600 && !until(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + await tester.pumpAndSettle(); +} + void main() { group('업데이트 창', () { late _FakeUpdater up; @@ -155,6 +168,14 @@ void main() { .writeAsStringSync(jsonEncode({'uiLanguage': 'ko', 'openSubtitlesKey': 'SECRET-KEY', 'lastRunVersion': '1.0.1'})); File(p.join(data.path, 'videos.json')).writeAsStringSync('[]'); VersionSnapshot.instance = VersionSnapshot(data.path, sharedDir: p.join(data.path, 'shared', '설정 보관')); + // P0: 받은 모델 폴더도 시험 폴더 안으로 (시험 프로그램 옆 models 를 건드리지 않게) + c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + updater: up, + models: ModelStore(p.join(data.path, 'models')))); + AiStore.instance = AiStore(data.path); + ModelBackup.sharedRoot = p.join(data.path, 'shared'); up.all = [ parseRelease(_release('v1.0.2'), allowPrerelease: true)!, parseRelease(_release('v1.0.1'), allowPrerelease: true)!, @@ -163,6 +184,8 @@ void main() { }); tearDown(() { VersionSnapshot.instance = null; + AiStore.instance = null; + ModelBackup.sharedRoot = null; data.deleteSync(recursive: true); }); @@ -219,9 +242,19 @@ void main() { await tester.tap(find.text('v1.0.0').first); await tester.pumpAndSettle(); await tester.tap(find.text('이 버전으로 되돌리기')); - await tester.pumpAndSettle(); + await settleIo(tester, until: () => find.text('저장하고 계속').evaluate().isNotEmpty); expect(find.textContaining('Android 는 버전이 낮은 앱을'), findsOneWidget); + // P0: 남는 것 / 사라지는 것을 사실대로 (받은 모델이 없으면 모델 줄은 없음) + expect(find.text('남는 것'), findsOneWidget); + expect(find.text('사라지는 것'), findsOneWidget); + expect(find.textContaining('비밀번호 · API 키'), findsOneWidget); + expect(find.textContaining('브라우저의 로그인'), findsOneWidget); + expect(find.textContaining('다시 받아야 합니다'), findsNothing); await tester.tap(find.text('저장하고 계속')); + await tester.pumpAndSettle(); + // 사라지는 것이 있으니 한 번 더 + expect(find.text('정말 되돌릴까요?'), findsOneWidget); + await tester.tap(find.text('되돌리기 계속')); // 받기 · 파일 복사 (실제 파일 작업) 가 끝날 때까지 for (var i = 0; i < 50 && find.text('저장했습니다').evaluate().isEmpty; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); @@ -234,6 +267,47 @@ void main() { await tester.pumpAndSettle(); expect(up.calls, ['download 1.0.0', 'uninstall']); }); + + testWidgets('P0 Android: 받은 AI 모델을 복사해 두기 (기본: 남은 공간이 2배 이상이면 켬) · 끄면 "다시 받아야" · 설정에 기억', (tester) async { + up.installsInPlace = true; + up.downloaded = (File(p.join(data.path, 'dl.apk'))..writeAsStringSync('apk')).path; + File(p.join(data.path, 'ai', 'models', 'esrgan.pth')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(2 << 20, 1)); // 2MB + File(p.join(data.path, 'models', 'whisper', 'ggml-base.bin')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(1 << 20, 2)); // 1MB + await openPicker(tester); + await tester.tap(find.text('v1.0.0').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('이 버전으로 되돌리기')); + await settleIo(tester, until: () => find.text('저장하고 계속').evaluate().isNotEmpty); + // 시험 PC 는 공간이 넉넉하니 처음 값은 켬 → 남는 것에 모델 (공용 폴더가 아직 없어도 상위 폴더로 잼) + expect(find.textContaining('남은 공간'), findsOneWidget); + expect(find.textContaining('받은 AI 모델 (3MB) - Download/JJ_MKVMaker/AI 모델 보관'), findsOneWidget); + // 예전 판 (v1.0.0) 은 옮겨 둔 모델 · 설정을 되살리지 못한다고 사실대로 + expect(find.textContaining('v1.0.0 은 복사해 둔 모델을 되살리지 못합니다'), findsOneWidget); + expect(find.textContaining('v1.0.0 은 저장해 둔 설정을 되살리지 못해'), findsOneWidget); + // 끄면 사라지는 것으로 · 설정에 기억 + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(find.textContaining('다시 받아야 합니다 (약 3MB)'), findsOneWidget); + expect(c.settings.rollbackKeepModels, 'off'); + // 다시 켜고 진행 → 공용 폴더에 모델 사본과 목록 + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(c.settings.rollbackKeepModels, 'on'); + await tester.tap(find.text('저장하고 계속')); + await tester.pumpAndSettle(); + await tester.tap(find.text('되돌리기 계속')); + await settleIo(tester, until: () => find.text('저장했습니다').evaluate().isNotEmpty); + expect(find.text('저장했습니다'), findsOneWidget); + final kept = ModelBackup.shared!; + expect(kept.exists, isTrue); + expect(kept.savedSize, 3 << 20); + expect(File(p.join(kept.dir, 'ai', 'models', 'esrgan.pth')).lengthSync(), 2 << 20); + expect(File(p.join(kept.dir, 'models', 'whisper', 'ggml-base.bin')).existsSync(), isTrue); + }); }); }); }