diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 8673168..7a4d4c9 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -34,6 +34,7 @@ "(설명 없음)": "(no description)", "+ 여기에 새 줄": "+ New line here", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", + "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", "0 으로": "Reset to 0", "10초 뒤로 (←)": "Back 10 seconds (←)", "10초 앞으로 (→)": "Forward 10 seconds (→)", @@ -59,6 +60,7 @@ "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.)", + "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.", "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\")", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", "Chrome (내려받기)": "Chrome (download)", @@ -145,6 +147,18 @@ "ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}", "quit 시작": "quit started", + "robocopy 옵션": "robocopy options", + "rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE. When off, only adds · updates in the target", + "rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "rsync is not available (Settings > File explorer: download · set path)", + "rsync 가져오기": "Get rsync", + "rsync 내려받기": "Download rsync", + "rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync is downloaded on first use (rsync 3.5.1 from the official MSYS2 repository · about 8MB, SHA256 checked for each file).\nIt is kept in the rsync folder of the settings folder and survives updates. Download now?", + "rsync 를 받지 못했습니다: {0}": "Could not download rsync: {0}", + "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "Run rsync separately for each item, or once for all (robocopy always runs per folder)", + "rsync 받는 중": "Downloading rsync", + "rsync 실행 파일": "rsync executable", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync executable not found. Check the path in Settings > File explorer.", + "rsync 옵션": "rsync options", "v{0} 받는 중": "Downloading v{0}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", @@ -163,6 +177,7 @@ "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "Use the YouTube sign-in cookies of the {0} browser (Chrome · Edge may need to be closed to be read)", "{0} 순으로 정렬{1}": "Sort by {0}{1}", "{0} 아래에 만듭니다": "Created under {0}", + "{0} 안의 파일": "Files in {0}", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", @@ -176,6 +191,7 @@ "{0}개": "{0}", "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", + "{0}개 맞춤": "{0} synced", "{0}개 선택": "{0} selected", "{0}개 표시함": "{0} marked", "{0}개 항목을 {1}": "{0} items {1}", @@ -189,6 +205,7 @@ "{0}시간 {1}분": "{0} h {1} min", "{0}줄": "{0} lines", "{0}초": "{0} s", + "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({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", "… 외 {0}개": "… and {0} more", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", @@ -222,6 +239,8 @@ "결과 폴더 열기: {0}": "Open output folder: {0}", "결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})", "계속 편집": "Keep editing", + "고르기": "Choose", + "고르지 않음": "Not chosen", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고지 문서": "Notices", "고화질": "High quality", @@ -243,6 +262,7 @@ "기본 화면 크기": "Default display size", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A", "기본값": "Default", + "기본값으로 ({0})": "Default ({0})", "기타 즐겨찾기": "Other bookmarks", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", "길이 {0}": "Length {0}", @@ -263,11 +283,13 @@ "날짜": "Date", "남은 시간 {0}": "{0} left", "내려받기": "Download", + "내려받기 (Windows 만)": "Download (Windows only)", "내려받기 실패 ({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", + "내려받은 rsync 지우기": "Delete downloaded rsync", "내보냈습니다: {0}": "Exported: {0}", "내역": "History", "내역 (최근 폴더)": "History (recent folders)", @@ -382,7 +404,10 @@ "맞추기 (검은 여백)": "Fit (black bars)", "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", "맞추기 (흐린 배경)": "Fit (blurred background)", + "맞추는 중…": "Syncing…", + "맞춤": "Synced", "매번 고르기": "Ask every time", + "멈춤": "Stopped", "모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", @@ -423,6 +448,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", "받음 {0}": "Received {0}", "밝기": "Brightness", + "방법: {0}": "Method: {0}", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", "백그라운드로 계속 (기본)": "Keep running in the background (default)", @@ -463,6 +489,7 @@ "비어 있음": "Empty", "사용 안 함": "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}", "사용자가 취소했습니다.": "Cancelled by the user.", "사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.", "삭제": "Delete", @@ -510,6 +537,8 @@ "세로 4:5": "Portrait 4:5", "세로 9:16": "Portrait 9:16", "세로 고정": "Portrait lock", + "속도 제한 (KB/s)": "Speed limit (KB/s)", + "속도 제한 {0} KB/s": "Speed limit {0} KB/s", "수정": "Edit", "수정 · 이동": "Edit · move", "수정한 날짜": "Date modified", @@ -532,6 +561,12 @@ "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때마다 설정 창 보기": "Show the setup window at every start", "시즌": "Season", + "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", + "실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}", + "실시간 동기화 실패: {0} → {1}: {2}": "Live sync failed: {0} → {1}: {2}", + "실시간 동기화 추가": "Add live sync", + "실시간 동기화: 대상 폴더": "Live sync: target folder", + "실시간 동기화: 원본 폴더": "Live sync: source folder", "실패": "Failed", "실패: {0}": "Failed: {0}", "실패: {0}\n{1}": "Failed: {0}\n{1}", @@ -549,10 +584,12 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])", "아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).", + "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])", "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}", "압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", + "앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again", "앱 아이콘": "App icon", "앱 안 브라우저 (권장)": "In-app browser (recommended)", @@ -569,6 +606,7 @@ "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", + "여러 개를 골랐을 때": "When several are selected", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", "연도": "Year", "열기": "Open", @@ -580,6 +618,8 @@ "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "Re-encodes the video (takes a long time, audio unchanged){0}", "영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "Recognizes the video's speech and translates it into the chosen languages. All processing happens on this {0}.", "영어": "English", + "예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "e.g. -avPog (archive · verbose · progress · owner · group). Move adds --remove-source-files automatically", + "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "e.g. /E /COPY:DAT /DCOPY:T /R:2 /W:2. Move adds /MOVE automatically", "예: 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})", @@ -609,6 +649,10 @@ "원본 비율": "Original aspect", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", + "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "When the source folder changes, the target is synced right away (while the app is running). Also syncs once at start.", + "원본 폴더가 없습니다": "Source folder not found", + "원본과 대상이 서로 안에 있습니다": "Source and target are inside each other", + "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본의 {0}배": "{0}× original", "원어 (영상 속 언어)": "Source language (spoken in the video)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", @@ -773,6 +817,7 @@ "저장했습니다: {0}": "Saved: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)", "적용": "Apply", + "전체 {0}개 중 {1}번째": "Item {1} of {0}", "전체 만들기": "Make all", "전체 목록": "Full list", "전체 선택": "Select all", @@ -825,18 +870,23 @@ "즐겨찾기 추가…": "Add bookmark…", "즐겨찾기 추가됨": "Bookmark added", "즐겨찾기 표시줄": "Bookmarks bar", + "지금 맞추기": "Sync now", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)", "지금 설치": "Install now", "지금 쓰는 버전: 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", + "지우기": "Delete", + "지우기 포함": "with delete", "지우지 못했습니다: {0}": "Could not delete: {0}", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", + "지정한 파일이 없습니다": "The specified file does not exist", "직접 입력": "Custom", "직접 입력…": "Custom…", + "직접 지정": "Specify", "진행 중인 작업 없음": "No jobs in progress", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?", "창": "Panes", @@ -854,6 +904,7 @@ "채도": "Saturation", "채우기 (가운데 잘라냄)": "Fill (crop the center)", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use", + "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", "첫 줄 추가": "Add first line", "청각장애인용": "Hearing impaired", @@ -891,7 +942,10 @@ "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", "트레이 정리": "Clean up tray", "파일": "File", + "파일 {0}/{1}": "Files {0}/{1}", "파일 관리자에서 보기": "Show in file manager", + "파일 복사 · 이동 방법": "File copy · move method", + "파일 세는 중…": "Counting files…", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "When JJ_MKVMaker is chosen from \"Open with\" · Share in a file app", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "The file location is unknown, so it will only be played. To make an MKV, choose it with \"Add videos\".", "파일 이름": "File name", @@ -900,6 +954,7 @@ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)", "파일 폴더": "File folder", "파일 확인 중…": "Checking files…", + "파일만 골랐을 때": "When only files are selected", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", "팝업 끄기": "Close pop-up", "팝업 보기": "Pop-up view", @@ -915,6 +970,7 @@ "폴더": "Folder", "폴더 + 파일 목록": "Folders + file list", "폴더 만들기": "New folder", + "폴더 복사 · 이동 방법": "Folder copy · move method", "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", "폴더 선택": "Choose folder", "폴더 수정": "Edit folder", @@ -923,6 +979,7 @@ "폴더 이름 바꾸기 · 이동": "Rename · move folder", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "The folder name has characters that cannot be used (\\ / : * ? \" < > |)", "폴더: {0}": "Folder: {0}", + "폴더가 들어 있을 때": "When folders are included", "폴더가 아닙니다: {0}": "Not a folder: {0}", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", "폴란드어": "Polish", @@ -951,13 +1008,16 @@ "하위 폴더가 없습니다": "No subfolders", "한 번: 바로 실행": "Single: open right away", "한 번: 선택 · 두 번: 실행 (기본)": "Single: select · Double: open (default)", + "한 번에": "All at once", "한 창": "One pane", "한국어": "Korean", "한글": "Hangul", + "항목마다 따로 (기본)": "Each item separately (default)", "해제": "Unset", "허용": "Allow", "허용했으면 다시 확인": "Check again after allowing", "헝가리어": "Hungarian", + "현재 방식": "Built-in", "현재 페이지": "Current page", "현재 페이지 추가": "Add current page", "형식 오류": "Format error", @@ -979,6 +1039,7 @@ "화질": "Quality", "확인": "OK", "확인 · 압축 푸는 중…": "Verifying · extracting…", + "확인 중…": "Checking…", "확장자": "Ext", "확장자별 재생 프로그램": "Player per file extension", "환경 설정": "Settings", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index e07ac39..6c8204e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -34,6 +34,7 @@ "(설명 없음)": "(説明なし)", "+ 여기에 새 줄": "+ ここに新しい行", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", + "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 으로": "0 に戻す", "10초 뒤로 (←)": "10秒戻る(←)", "10초 앞으로 (→)": "10秒進む(→)", @@ -59,6 +60,7 @@ "API 키": "API キー", "API 키 (필수, 무료)": "API キー(必須・無料)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", + "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", "Chrome (내려받기)": "Chrome(ダウンロード)", @@ -145,6 +147,18 @@ "ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}", "quit 시작": "終了開始", + "robocopy 옵션": "robocopy オプション", + "rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。オフなら対象に追加 · 更新のみ", + "rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "rsync がありません (環境設定 > ファイルエクスプローラーでダウンロード · パス指定)", + "rsync 가져오기": "rsync の入手", + "rsync 내려받기": "rsync をダウンロード", + "rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync は初めて使うときにダウンロードします (MSYS2 公式リポジトリの rsync 3.5.1 · 約 8MB、ファイルごとに SHA256 を確認)。\n設定フォルダーの rsync に置くため、更新しても残ります。今ダウンロードしますか?", + "rsync 를 받지 못했습니다: {0}": "rsync をダウンロードできませんでした: {0}", + "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync を項目ごとに実行するか、まとめて 1 回で実行 (robocopy は常にフォルダーごと)", + "rsync 받는 중": "rsync をダウンロード中", + "rsync 실행 파일": "rsync 実行ファイル", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。", + "rsync 옵션": "rsync オプション", "v{0} 받는 중": "v{0} をダウンロード中", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", @@ -163,6 +177,7 @@ "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)", "{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}", "{0} 아래에 만듭니다": "{0} の下に作成します", + "{0} 안의 파일": "{0} 内のファイル", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", @@ -176,6 +191,7 @@ "{0}개": "{0}個", "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", + "{0}개 맞춤": "{0} 件を同期", "{0}개 선택": "{0}個選択", "{0}개 표시함": "{0} 件をマーク", "{0}개 항목을 {1}": "{0} 件の項目を{1}", @@ -189,6 +205,7 @@ "{0}시간 {1}분": "{0}時間 {1}分", "{0}줄": "{0}行", "{0}초": "{0}秒", + "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", "… 외 {0}개": "… ほか {0} 件", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", @@ -222,6 +239,8 @@ "결과 폴더 열기: {0}": "出力フォルダーを開く: {0}", "결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})", "계속 편집": "編集を続ける", + "고르기": "選ぶ", + "고르지 않음": "未選択", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고지 문서": "告知文書", "고화질": "高画質", @@ -243,6 +262,7 @@ "기본 화면 크기": "既定の画面サイズ", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存", "기본값": "既定値", + "기본값으로 ({0})": "既定値に戻す ({0})", "기타 즐겨찾기": "その他のブックマーク", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", "길이 {0}": "長さ {0}", @@ -263,11 +283,13 @@ "날짜": "日付", "남은 시간 {0}": "残り時間 {0}", "내려받기": "ダウンロード", + "내려받기 (Windows 만)": "ダウンロード (Windows のみ)", "내려받기 실패 ({0})": "ダウンロード失敗({0})", "내려받기 실패 ({0}): {1}": "ダウンロード失敗({0}): {1}", "내려받는 중": "ダウンロード中", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", + "내려받은 rsync 지우기": "ダウンロードした rsync を削除", "내보냈습니다: {0}": "書き出しました: {0}", "내역": "履歴", "내역 (최근 폴더)": "履歴 (最近のフォルダー)", @@ -382,7 +404,10 @@ "맞추기 (검은 여백)": "フィット(黒い余白)", "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", "맞추기 (흐린 배경)": "フィット(ぼかし背景)", + "맞추는 중…": "同期中…", + "맞춤": "同期済み", "매번 고르기": "毎回選ぶ", + "멈춤": "停止", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", @@ -423,6 +448,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받음 {0}": "受信 {0}", "밝기": "明るさ", + "방법: {0}": "方法: {0}", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", "백그라운드로 계속 (기본)": "バックグラウンドで続ける(既定)", @@ -463,6 +489,7 @@ "비어 있음": "空", "사용 안 함": "使用しない", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください", + "사용: {0}": "使用: {0}", "사용자가 취소했습니다.": "ユーザーがキャンセルしました。", "사용할 수 있는 자막 사이트가 없습니다.": "使用できる字幕サイトがありません。", "삭제": "削除", @@ -510,6 +537,8 @@ "세로 4:5": "縦 4:5", "세로 9:16": "縦 9:16", "세로 고정": "縦向き固定", + "속도 제한 (KB/s)": "速度制限 (KB/s)", + "속도 제한 {0} KB/s": "速度制限 {0} KB/s", "수정": "編集", "수정 · 이동": "編集・移動", "수정한 날짜": "更新日時", @@ -532,6 +561,12 @@ "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시즌": "シーズン", + "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", + "실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}", + "실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}", + "실시간 동기화 추가": "リアルタイム同期を追加", + "실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー", + "실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー", "실패": "失敗", "실패: {0}": "失敗: {0}", "실패: {0}\n{1}": "失敗: {0}\n{1}", @@ -549,10 +584,12 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。", + "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)", "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", + "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", "앱 아이콘": "アプリアイコン", "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", @@ -569,6 +606,7 @@ "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", + "여러 개를 골랐을 때": "複数選んだとき", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "연도": "年", "열기": "開く", @@ -580,6 +618,8 @@ "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "映像を再エンコードします(時間がかかります、音声はそのまま){0}", "영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "動画の音声を認識し、選んだ言語に翻訳します。すべての処理はこの{0}で行われます。", "영어": "英語", + "예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (アーカイブ · 詳細 · 進捗 · 所有者 · グループ)。移動では --remove-source-files を自動で付加", + "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})", @@ -609,6 +649,10 @@ "원본 비율": "元の比率", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", + "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。", + "원본 폴더가 없습니다": "元フォルダーがありません", + "원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります", + "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본의 {0}배": "元の {0}倍", "원어 (영상 속 언어)": "原語(動画内の言語)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", @@ -773,6 +817,7 @@ "저장했습니다: {0}": "保存しました: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)", "적용": "適用", + "전체 {0}개 중 {1}번째": "全 {0} 件中 {1} 件目", "전체 만들기": "すべて作成", "전체 목록": "全体リスト", "전체 선택": "すべて選択", @@ -825,18 +870,23 @@ "즐겨찾기 추가…": "ブックマークを追加…", "즐겨찾기 추가됨": "ブックマークを追加しました", "즐겨찾기 표시줄": "ブックマークバー", + "지금 맞추기": "今すぐ同期", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)", "지금 설치": "今すぐインストール", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금: {0}": "現在: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 작업 기록": "過去の作業記録", + "지우기": "削除", + "지우기 포함": "削除あり", "지우지 못했습니다: {0}": "削除できませんでした: {0}", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", + "지정한 파일이 없습니다": "指定したファイルがありません", "직접 입력": "直接入力", "직접 입력…": "直接入力…", + "직접 지정": "直接指定", "진행 중인 작업 없음": "進行中の作業なし", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?", "창": "ペイン", @@ -854,6 +904,7 @@ "채도": "彩度", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", + "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", "첫 줄 추가": "最初の行を追加", "청각장애인용": "聴覚障害者用", @@ -891,7 +942,10 @@ "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", "트레이 정리": "トレイの後片付け", "파일": "ファイル", + "파일 {0}/{1}": "ファイル {0}/{1}", "파일 관리자에서 보기": "ファイルマネージャーで表示", + "파일 복사 · 이동 방법": "ファイルのコピー · 移動方法", + "파일 세는 중…": "ファイルを数えています…", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "ファイルアプリの「アプリで開く」・共有で JJ_MKVMaker を選んだとき", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "ファイルの場所がわからないため再生のみ行います。MKV を作成するには「動画を追加」で選んでください。", "파일 이름": "ファイル名", @@ -900,6 +954,7 @@ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)", "파일 폴더": "ファイル フォルダー", "파일 확인 중…": "ファイルを確認中…", + "파일만 골랐을 때": "ファイルだけを選んだとき", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", "팝업 끄기": "ポップアップを閉じる", "팝업 보기": "ポップアップ表示", @@ -915,6 +970,7 @@ "폴더": "フォルダー", "폴더 + 파일 목록": "フォルダー + ファイル一覧", "폴더 만들기": "フォルダーを作成", + "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", "폴더 선택": "フォルダーを選択", "폴더 수정": "フォルダーを編集", @@ -923,6 +979,7 @@ "폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "フォルダー名に使えない文字があります(\\ / : * ? \" < > |)", "폴더: {0}": "フォルダー: {0}", + "폴더가 들어 있을 때": "フォルダーを含むとき", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴란드어": "ポーランド語", @@ -951,13 +1008,16 @@ "하위 폴더가 없습니다": "サブフォルダーがありません", "한 번: 바로 실행": "1 回: すぐに開く", "한 번: 선택 · 두 번: 실행 (기본)": "1 回: 選択 · 2 回: 開く (既定)", + "한 번에": "まとめて 1 回", "한 창": "1 ペイン", "한국어": "韓国語", "한글": "ハングル", + "항목마다 따로 (기본)": "項目ごとに (既定)", "해제": "解除", "허용": "許可", "허용했으면 다시 확인": "許可したらもう一度確認", "헝가리어": "ハンガリー語", + "현재 방식": "現在の方式", "현재 페이지": "現在のページ", "현재 페이지 추가": "現在のページを追加", "형식 오류": "形式エラー", @@ -979,6 +1039,7 @@ "화질": "画質", "확인": "OK", "확인 · 압축 푸는 중…": "確認・展開中…", + "확인 중…": "確認中…", "확장자": "拡張子", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "환경 설정": "環境設定", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index df6a2f9..a2f2bfa 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -34,6 +34,7 @@ "(설명 없음)": "(无说明)", "+ 여기에 새 줄": "+ 在此新增一行", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", + "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 으로": "归零", "10초 뒤로 (←)": "后退 10 秒(←)", "10초 앞으로 (→)": "前进 10 秒(→)", @@ -59,6 +60,7 @@ "API 키": "API 密钥", "API 키 (필수, 무료)": "API 密钥(必填,免费)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", + "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", "Chrome (내려받기)": "Chrome(下载)", @@ -145,6 +147,18 @@ "ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}", "quit 시작": "开始退出", + "robocopy 옵션": "robocopy 选项", + "rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。关闭时只在目标中添加 · 更新", + "rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "没有 rsync (设置 > 文件浏览器中下载 · 指定路径)", + "rsync 가져오기": "获取 rsync", + "rsync 내려받기": "下载 rsync", + "rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync 在首次使用时下载 (MSYS2 官方仓库的 rsync 3.5.1 · 约 8MB,逐个文件校验 SHA256)。\n保存在设置文件夹的 rsync 中,更新后仍保留。现在下载吗?", + "rsync 를 받지 못했습니다: {0}": "无法下载 rsync: {0}", + "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync 按项目分别执行,或一次执行 (robocopy 总是按文件夹)", + "rsync 받는 중": "正在下载 rsync", + "rsync 실행 파일": "rsync 可执行文件", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。", + "rsync 옵션": "rsync 选项", "v{0} 받는 중": "正在下载 v{0}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", @@ -163,6 +177,7 @@ "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)", "{0} 순으로 정렬{1}": "按{0}排序{1}", "{0} 아래에 만듭니다": "在 {0} 下创建", + "{0} 안의 파일": "{0} 中的文件", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", @@ -176,6 +191,7 @@ "{0}개": "{0} 个", "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", + "{0}개 맞춤": "已同步 {0} 个", "{0}개 선택": "已选 {0} 个", "{0}개 표시함": "已标记 {0} 个", "{0}개 항목을 {1}": "{0} 个项目{1}", @@ -189,6 +205,7 @@ "{0}시간 {1}분": "{0} 小时 {1} 分", "{0}줄": "{0} 行", "{0}초": "{0} 秒", + "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", "… 외 {0}개": "… 另外 {0} 个", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", @@ -222,6 +239,8 @@ "결과 폴더 열기: {0}": "打开输出文件夹: {0}", "결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})", "계속 편집": "继续编辑", + "고르기": "选择", + "고르지 않음": "未选择", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고지 문서": "声明文件", "고화질": "高画质", @@ -243,6 +262,7 @@ "기본 화면 크기": "默认显示大小", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A", "기본값": "默认值", + "기본값으로 ({0})": "恢复默认 ({0})", "기타 즐겨찾기": "其他书签", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", "길이 {0}": "时长 {0}", @@ -263,11 +283,13 @@ "날짜": "日期", "남은 시간 {0}": "剩余时间 {0}", "내려받기": "下载", + "내려받기 (Windows 만)": "下载 (仅 Windows)", "내려받기 실패 ({0})": "下载失败({0})", "내려받기 실패 ({0}): {1}": "下载失败({0}): {1}", "내려받는 중": "正在下载", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", + "내려받은 rsync 지우기": "删除已下载的 rsync", "내보냈습니다: {0}": "已导出: {0}", "내역": "历史", "내역 (최근 폴더)": "历史 (最近的文件夹)", @@ -382,7 +404,10 @@ "맞추기 (검은 여백)": "适应(黑边)", "맞추기 (비율이 다를 때)": "适配方式(比例不同时)", "맞추기 (흐린 배경)": "适应(模糊背景)", + "맞추는 중…": "正在同步…", + "맞춤": "已同步", "매번 고르기": "每次选择", + "멈춤": "已停止", "모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", @@ -423,6 +448,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받음 {0}": "已接收 {0}", "밝기": "亮度", + "방법: {0}": "方式: {0}", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", "백그라운드로 계속 (기본)": "在后台继续(默认)", @@ -463,6 +489,7 @@ "비어 있음": "空", "사용 안 함": "不使用", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置", + "사용: {0}": "使用: {0}", "사용자가 취소했습니다.": "用户已取消。", "사용할 수 있는 자막 사이트가 없습니다.": "没有可用的字幕网站。", "삭제": "删除", @@ -510,6 +537,8 @@ "세로 4:5": "竖屏 4:5", "세로 9:16": "竖屏 9:16", "세로 고정": "锁定竖屏", + "속도 제한 (KB/s)": "速度限制 (KB/s)", + "속도 제한 {0} KB/s": "速度限制 {0} KB/s", "수정": "编辑", "수정 · 이동": "编辑 · 移动", "수정한 날짜": "修改日期", @@ -532,6 +561,12 @@ "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시즌": "季", + "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", + "실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}", + "실시간 동기화 실패: {0} → {1}: {2}": "实时同步失败: {0} → {1}: {2}", + "실시간 동기화 추가": "添加实时同步", + "실시간 동기화: 대상 폴더": "实时同步: 目标文件夹", + "실시간 동기화: 원본 폴더": "实时同步: 源文件夹", "실패": "失败", "실패: {0}": "失败: {0}", "실패: {0}\n{1}": "失败: {0}\n{1}", @@ -549,10 +584,12 @@ "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。", + "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)", "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", "압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축을 풀 수 없습니다: {0}": "无法解压: {0}", + "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", "앱 아이콘": "应用图标", "앱 안 브라우저 (권장)": "应用内浏览器(推荐)", @@ -569,6 +606,7 @@ "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", + "여러 개를 골랐을 때": "选择多个时", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "연도": "年份", "열기": "打开", @@ -580,6 +618,8 @@ "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "重新编码视频(耗时较长,音频不变){0}", "영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "识别视频中的语音并翻译成所选语言。所有处理均在此{0}上完成。", "영어": "英语", + "예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (归档 · 详细 · 进度 · 所有者 · 组)。移动时自动加上 --remove-source-files", + "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})", @@ -609,6 +649,10 @@ "원본 비율": "原始比例", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", + "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。", + "원본 폴더가 없습니다": "源文件夹不存在", + "원본과 대상이 서로 안에 있습니다": "源与目标互相包含", + "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본의 {0}배": "原始的 {0} 倍", "원어 (영상 속 언어)": "原语言(视频中的语言)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", @@ -773,6 +817,7 @@ "저장했습니다: {0}": "已保存: {0}", "저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)", "적용": "应用", + "전체 {0}개 중 {1}번째": "共 {0} 个中的第 {1} 个", "전체 만들기": "全部制作", "전체 목록": "完整列表", "전체 선택": "全选", @@ -825,18 +870,23 @@ "즐겨찾기 추가…": "添加书签…", "즐겨찾기 추가됨": "已添加书签", "즐겨찾기 표시줄": "书签栏", + "지금 맞추기": "立即同步", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)", "지금 설치": "立即安装", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금: {0}": "当前: {0}", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 작업 기록": "旧任务记录", + "지우기": "删除", + "지우기 포함": "含删除", "지우지 못했습니다: {0}": "无法删除: {0}", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", + "지정한 파일이 없습니다": "指定的文件不存在", "직접 입력": "手动输入", "직접 입력…": "手动输入…", + "직접 지정": "手动指定", "진행 중인 작업 없음": "没有进行中的任务", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?", "창": "窗格", @@ -854,6 +904,7 @@ "채도": "饱和度", "채우기 (가운데 잘라냄)": "填充(裁切中间)", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", + "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", "첫 줄 추가": "添加第一行", "청각장애인용": "听障字幕", @@ -891,7 +942,10 @@ "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", "트레이 정리": "清理托盘", "파일": "文件", + "파일 {0}/{1}": "文件 {0}/{1}", "파일 관리자에서 보기": "在文件管理器中显示", + "파일 복사 · 이동 방법": "文件复制 · 移动方式", + "파일 세는 중…": "正在统计文件…", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "在文件应用的“打开方式” · 分享中选择 JJ_MKVMaker 时", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "无法确定文件位置,因此仅播放。要制作 MKV,请用“添加视频”选择。", "파일 이름": "文件名", @@ -900,6 +954,7 @@ "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)", "파일 폴더": "文件夹", "파일 확인 중…": "正在检查文件…", + "파일만 골랐을 때": "只选择文件时", "파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}", "팝업 끄기": "关闭弹出窗口", "팝업 보기": "弹出窗口观看", @@ -915,6 +970,7 @@ "폴더": "文件夹", "폴더 + 파일 목록": "文件夹 + 文件列表", "폴더 만들기": "新建文件夹", + "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", "폴더 선택": "选择文件夹", "폴더 수정": "编辑文件夹", @@ -923,6 +979,7 @@ "폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "文件夹名称中有不能使用的字符(\\ / : * ? \" < > |)", "폴더: {0}": "文件夹: {0}", + "폴더가 들어 있을 때": "包含文件夹时", "폴더가 아닙니다: {0}": "不是文件夹: {0}", "폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", "폴란드어": "波兰语", @@ -951,13 +1008,16 @@ "하위 폴더가 없습니다": "没有子文件夹", "한 번: 바로 실행": "单击: 立即打开", "한 번: 선택 · 두 번: 실행 (기본)": "单击: 选择 · 双击: 打开 (默认)", + "한 번에": "一次执行", "한 창": "单窗格", "한국어": "韩语", "한글": "韩文", + "항목마다 따로 (기본)": "逐个项目 (默认)", "해제": "解除", "허용": "允许", "허용했으면 다시 확인": "允许后重新检查", "헝가리어": "匈牙利语", + "현재 방식": "当前方式", "현재 페이지": "当前页面", "현재 페이지 추가": "添加当前页面", "형식 오류": "格式错误", @@ -979,6 +1039,7 @@ "화질": "画质", "확인": "确定", "확인 · 압축 푸는 중…": "正在校验 · 解压…", + "확인 중…": "正在检查…", "확장자": "扩展名", "확장자별 재생 프로그램": "按扩展名设置播放程序", "환경 설정": "设置", diff --git a/integration_test/copy_sync_shot_test.dart b/integration_test/copy_sync_shot_test.dart new file mode 100644 index 0000000..342424c --- /dev/null +++ b/integration_test/copy_sync_shot_test.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/copy_sync_settings.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; + +/// 환경 설정 > 파일 탐색기 의 복사 · 동기화 부분을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('복사 · 동기화 설정 그림', (tester) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..copyMethodFolder = 'rsync' + ..copyBandwidthKBps = 5000 + ..liveSyncPairs = [ + LiveSyncPair(r'D:\영상\원본', r'\\nas\backup\영상', method: 'rsync', delete: true), + LiveSyncPair(r'C:\Users\me\Documents', r'E:\Documents', enabled: false), + ]; + final live = LiveSync(c); + final key = GlobalKey(); + tester.view.physicalSize = const Size(1400, 1300); + tester.view.devicePixelRatio = 1; + await tester.pumpWidget(MaterialApp( + theme: buildTheme(), + home: RepaintBoundary( + key: key, + child: Scaffold(body: SingleChildScrollView(child: CopySyncSettings(c: c))), + ), + )); + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/copy_sync.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + live.dispose(); + tester.view.reset(); + }); +} diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart new file mode 100644 index 0000000..bf6b4e1 --- /dev/null +++ b/lib/app/live_sync.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../core/file_ops.dart'; +import '../core/sync_tools.dart'; +import '../l10n/tr.dart'; +import '../platform/windows/rsync_installer.dart'; +import 'app_controller.dart'; +import 'settings.dart'; + +/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null). +/// - 직접 지정: 그 파일 +/// - 처음 쓸 때 내려받기 (Windows): 내려받아 둔 것 +Future rsyncExecutable(AppSettings s) async { + if (s.rsyncSource == 'custom') { + return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null; + } + if (!Platform.isWindows) return null; + return RsyncInstaller.installedPath(); +} + +/// 이 기기에서 쓸 수 있는 방법 +bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { + CopyMethod.builtin => true, + CopyMethod.robocopy => Platform.isWindows, + CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom', + }; + +/// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. +/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [AppSettings.liveSyncIntervalSec] 마다 살핀다. +/// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다. +class LiveSync extends ChangeNotifier { + final AppController c; + LiveSync(this.c); + + static LiveSync? instance; + + final _watch = >{}; + final _timers = {}; + final _pending = {}; + final _running = {}; + + /// 쌍마다 마지막 결과 (화면 표시용): (시각, 글) + final status = {}; + + String _sig = ''; + + void start() { + instance = this; + c.addListener(_apply); + _apply(); + } + + @override + void dispose() { + c.removeListener(_apply); + _stopAll(); + if (instance == this) instance = null; + super.dispose(); + } + + static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}'; + + void _stopAll() { + for (final s in _watch.values) { + s.cancel(); + } + for (final t in [..._timers.values, ..._pending.values]) { + t.cancel(); + } + _watch.clear(); + _timers.clear(); + _pending.clear(); + } + + /// 설정이 바뀌면 감시를 다시 꾸린다 + void _apply() { + final s = c.settings; + final pairs = s.liveSyncPairs.where((x) => x.enabled).toList(); + final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]); + if (sig == _sig) return; + _sig = sig; + _stopAll(); + for (final x in pairs) { + final k = keyOf(x); + if (!Directory(x.source).existsSync()) { + status[k] = (DateTime.now(), tr('원본 폴더가 없습니다')); + continue; + } + // 대상이 원본 안이면 맞출 때마다 원본이 바뀌어 끝없이 돈다 + if (isSameOrInside(x.target, x.source) || isSameOrInside(x.source, x.target)) { + status[k] = (DateTime.now(), tr('원본과 대상이 서로 안에 있습니다')); + continue; + } + if (Platform.isWindows) { + try { + _watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x)); + } catch (_) { + _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); + } + } else { + // Android 등: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다 + _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); + } + _schedule(x); // 시작할 때 한 번 + } + notifyListeners(); + } + + void _schedule(LiveSyncPair x) { + final k = keyOf(x); + _pending[k]?.cancel(); + _pending[k] = Timer(const Duration(seconds: 3), () => syncNow(x)); + } + + bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x)); + + /// 지금 맞추기 (원본 내용 → 대상) + Future syncNow(LiveSyncPair x) async { + final k = keyOf(x); + if (_running.contains(k)) { + _schedule(x); // 도는 중에 바뀐 것은 끝난 뒤 다시 + return; + } + _running.add(k); + notifyListeners(); + final s = c.settings; + try { + final method = CopyMethod.of(x.method); + final n = switch (method) { + CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps).mirror(x.source, x.target, delete: x.delete), + CopyMethod.rsync => await _rsync(x, s), + CopyMethod.robocopy => await _robocopy(x, s), + }; + status[k] = (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); + if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); + } catch (e) { + status[k] = (DateTime.now(), trf('실패: {0}', [e])); + c.note(trf('실시간 동기화 실패: {0} → {1}: {2}', [x.source, x.target, e])); + } finally { + _running.remove(k); + notifyListeners(); + } + } + + /// rsync: 원본 "내용" 을 대상으로 (끝에 / ), 지우기는 --delete. 보낸 파일 수 (모르면 -1) + Future _rsync(LiveSyncPair x, AppSettings s) async { + final exe = await rsyncExecutable(s); + if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); + final win = Platform.isWindows; + final args = [ + ...splitOptions(s.rsyncOptions).where((o) => o != '-P' && o != '--progress'), + if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', + if (x.delete) '--delete', + '${toCygwinPath(x.source, windows: win)}/', + '${toCygwinPath(x.target, windows: win)}/', + ]; + await Directory(x.target).create(recursive: true); + final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8); + if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode); + return RsyncOutput().feed('${r.stdout}\n').length; + } + + Future _robocopy(LiveSyncPair x, AppSettings s) async { + final opts = splitOptions(s.robocopyOptions); + final r = await Process.run('robocopy', [ + x.source, + x.target, + ...opts, + if (!opts.any((o) => o.toUpperCase() == '/E' || o.toUpperCase() == '/MIR')) '/E', + if (x.delete) '/PURGE', + if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}', + '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', + ]); + if (!robocopyOk(r.exitCode)) throw ProcessException('robocopy', const [], '${r.stdout}'.trim(), r.exitCode); + return RobocopyOutput().feed('${r.stdout}\n').length; + } +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 4e864ac..cb8c3d5 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -29,6 +29,34 @@ class MoveTarget { int get hashCode => Object.hash(name, dir); } +/// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다) +class LiveSyncPair { + final String source; + final String target; + + /// 'builtin' · 'rsync' · 'robocopy' (CopyMethod 이름) + final String method; + + /// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE) + final bool delete; + final bool enabled; + const LiveSyncPair(this.source, this.target, {this.method = 'builtin', this.delete = false, this.enabled = true}); + + LiveSyncPair copyWith({String? method, bool? delete, bool? enabled}) => + LiveSyncPair(source, target, method: method ?? this.method, delete: delete ?? this.delete, enabled: enabled ?? this.enabled); + + Map toJson() => + {'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled}; + + factory LiveSyncPair.fromJson(Map j) => LiveSyncPair( + j['source'] as String, + j['target'] as String, + method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin', + delete: j['delete'] == true, + enabled: j['enabled'] != false, + ); +} + /// 환경 설정 (settings.json 에 저장) class AppSettings { /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv @@ -158,6 +186,29 @@ class AppSettings { bool explorerSortDesc = false; bool explorerShowHidden = false; + // ── 복사 · 이동 · 동기화 (파일 탐색기) ── + /// 방법: 'builtin' 현재 방식 (기본) · 'rsync' · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때 + String copyMethodFile = 'builtin'; + String copyMethodFolder = 'builtin'; + String rsyncOptions = '-avPog'; + String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + + /// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync) + String copyRunMode = 'each'; + + /// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게) + int copyBandwidthKBps = 0; + + /// rsync 가져오기: 'download' 처음 쓸 때 내려받기 (Windows 기본) · 'custom' 직접 지정한 실행 파일 ([rsyncPath]) + String rsyncSource = 'download'; + String rsyncPath = ''; + + /// 실시간 동기화 (lsyncd 처럼) + List liveSyncPairs = []; + + /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. + int liveSyncIntervalSec = 30; + /// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창) List explorerPaths = []; @@ -240,6 +291,16 @@ class AppSettings { 'explorerSortDesc': explorerSortDesc, 'explorerShowHidden': explorerShowHidden, 'explorerPaths': explorerPaths, + 'copyMethodFile': copyMethodFile, + 'copyMethodFolder': copyMethodFolder, + 'rsyncOptions': rsyncOptions, + 'robocopyOptions': robocopyOptions, + 'copyRunMode': copyRunMode, + 'copyBandwidthKBps': copyBandwidthKBps, + 'rsyncSource': rsyncSource, + 'rsyncPath': rsyncPath, + 'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()], + 'liveSyncIntervalSec': liveSyncIntervalSec, 'explorerHistory': explorerHistory, 'autoCheckUpdates': autoCheckUpdates, 'lastUpdateCheck': lastUpdateCheck, @@ -264,6 +325,8 @@ class AppSettings { 'aiWhisper': aiWhisper, }; + static String _method(Object? v) => const ['rsync', 'robocopy'].contains(v) ? v as String : 'builtin'; + /// 색 보정 값 (-100 ~ 100) static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); @@ -327,6 +390,19 @@ class AppSettings { ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false ..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] + ..copyMethodFile = _method(j['copyMethodFile']) + ..copyMethodFolder = _method(j['copyMethodFolder']) + ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog' + ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' + ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' + ..copyBandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000) + ..rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download' + ..rsyncPath = j['rsyncPath'] as String? ?? '' + ..liveSyncPairs = [ + for (final x in (j['liveSyncPairs'] as List?) ?? const []) + if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x), + ] + ..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600) ..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x'] ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart new file mode 100644 index 0000000..fd12763 --- /dev/null +++ b/lib/app/transfer_job.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../core/file_ops.dart'; +import '../core/sync_tools.dart'; + +/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: +/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 +/// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지 +class TransferJob extends ChangeNotifier { + final List sources; + final String dest; + final bool move; + final CopyMethod method; + + /// rsync · robocopy 옵션 글자 + final String options; + + /// 여러 항목을 rsync 한 번으로 (false 면 항목마다 따로) + final bool once; + final int bandwidthKBps; + + /// rsync 실행 파일 (rsync 방법일 때) + final String? rsyncExe; + + TransferJob({ + required this.sources, + required this.dest, + required this.move, + this.method = CopyMethod.builtin, + this.options = '', + this.once = false, + this.bandwidthKBps = 0, + this.rsyncExe, + }); + + // ── 진행 상태 ── + int index = 0; // 지금 항목 + int get total => sources.length; + String get currentName => index < sources.length ? p.basename(sources[index]) : ''; + late final List filesTotal = List.filled(sources.length, 0); + late final List filesDone = List.filled(sources.length, 0); + + /// 지금 파일의 진행률 (rsync -P · robocopy 가 알려 줄 때) + double? filePercent; + bool counting = true; + bool finished = false; + Object? error; + bool cancelled = false; + final made = []; + + /// 마지막 출력 몇 줄 (실패했을 때 보여 줌) + final log = []; + + int get allFiles => filesTotal.fold(0, (a, b) => a + b); + int get allDone => filesDone.fold(0, (a, b) => a + b); + + /// 위쪽 진행률: 끝낸 항목 + 지금 항목의 파일 진행 + double get overall { + if (finished) return 1; + if (total == 0) return 0; + final cur = index < total && filesTotal[index] > 0 ? (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0) : 0.0; + return ((index + cur) / total).clamp(0.0, 1.0); + } + + /// 아래쪽 진행률: 지금 항목의 파일 + double get current { + if (finished) return 1; + if (index >= total || filesTotal[index] == 0) return 0; + return (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0); + } + + Process? _proc; + FileOps? _ops; + + void cancel() { + cancelled = true; + _ops?.cancel(); + _proc?.kill(); + notifyListeners(); + } + + void _tick() => notifyListeners(); + + static Future countFiles(String path) async { + if (!FileSystemEntity.isDirectorySync(path)) return 1; + var n = 0; + await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { + if (e is File) n++; + } + return n; + } + + Future run() async { + try { + for (var i = 0; i < sources.length; i++) { + final s = sources[i]; + if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) { + throw FileSystemException('폴더를 자기 안으로 복사 · 이동할 수 없습니다', s); + } + filesTotal[i] = await countFiles(s); + } + counting = false; + _tick(); + switch (method) { + case CopyMethod.builtin: + await _runBuiltin(); + case CopyMethod.rsync: + await _runRsync(); + case CopyMethod.robocopy: + await _runRobocopy(); + } + if (cancelled) throw const FileOpCancelled(); + } catch (e) { + error = cancelled ? const FileOpCancelled() : e; + } finally { + finished = true; + _tick(); + } + } + + Future _runBuiltin() async { + for (index = 0; index < sources.length; index++) { + if (cancelled) return; + final i = index; + _ops = FileOps(bandwidthKBps: bandwidthKBps, onFileDone: (_) { + filesDone[i]++; + _tick(); + }); + _tick(); + final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); + made.addAll(r); + filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우 + } + index = sources.length; + } + + void _addLog(String line) { + if (line.trim().isEmpty) return; + log.add(line.trim()); + if (log.length > 20) log.removeAt(0); + } + + Future _start(String exe, List args, Encoding enc, void Function(String chunk) onOut) async { + _proc = await Process.start(exe, args, runInShell: false); + final a = _proc!.stdout.transform(enc.decoder).listen(onOut); + final b = _proc!.stderr.transform(enc.decoder).listen((t) { + for (final l in t.split(RegExp(r'[\r\n]'))) { + _addLog(l); + } + }); + final code = await _proc!.exitCode; + await a.cancel(); + await b.cancel(); + _proc = null; + return code; + } + + Future _runRsync() async { + final exe = rsyncExe; + if (exe == null || exe.isEmpty) throw StateError('rsync 실행 파일이 없습니다'); + final windows = Platform.isWindows; + Future one(List idx) async { + final out = RsyncOutput(); + final names = [for (final i in idx) p.basename(sources[i])]; + final code = await _start( + exe, + rsyncArgs( + options: options, + sources: [for (final i in idx) sources[i]], + dest: dest, + bandwidthKBps: bandwidthKBps, + move: move, + windows: windows), + utf8, + (chunk) { + for (final f in out.feed(chunk)) { + _addLog(f); + // 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 + final first = f.split('/').first; + var k = idx.indexWhere((i) => p.basename(sources[i]) == first); + if (k < 0) k = names.indexOf(f); + final at = k < 0 ? index : idx[k]; + if (at > index) index = at; + filesDone[at]++; + } + filePercent = out.currentPercent; + _tick(); + }, + ); + if (cancelled) return; + if (!rsyncOk(code)) throw ProcessException(exe, const [], 'rsync 종료 코드 $code\n${log.take(8).join('\n')}', code); + for (final i in idx) { + filesDone[i] = filesTotal[i]; + made.add(p.join(dest, p.basename(sources[i]))); + // 이동: rsync 는 파일만 지우므로 빈 폴더를 정리 + if (move && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) { + await Directory(sources[i]).delete(recursive: true); + } + } + } + + if (once) { + index = 0; + await one([for (var i = 0; i < sources.length; i++) i]); + } else { + for (index = 0; index < sources.length; index++) { + if (cancelled) return; + _tick(); + await one([index]); + } + } + index = sources.length; + } + + Future _runRobocopy() async { + // robocopy 는 폴더마다 (파일은 같은 폴더끼리) 한 번씩 + for (index = 0; index < sources.length; index++) { + if (cancelled) return; + final s = sources[index]; + final dir = FileSystemEntity.isDirectorySync(s); + final runs = robocopyRuns( + options: options, + folders: dir ? [s] : const [], + files: dir ? const [] : [s], + dest: dest, + bandwidthKBps: bandwidthKBps, + move: move, + ); + _tick(); + for (final args in runs) { + final out = RobocopyOutput(); + final i = index; + final code = await _start( + 'robocopy', + [...args, '/BYTES', '/NJH', '/NJS', '/NDL', '/FP'], + systemEncoding, + (chunk) { + for (final f in out.feed(chunk)) { + _addLog(f); + filesDone[i]++; + } + filePercent = out.currentPercent; + _tick(); + }, + ); + if (cancelled) return; + if (!robocopyOk(code)) throw ProcessException('robocopy', const [], 'robocopy 종료 코드 $code', code); + } + filesDone[index] = filesTotal[index]; + made.add(p.join(dest, p.basename(s))); + } + index = sources.length; + } +} diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 4920853..833efd9 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -97,6 +97,27 @@ class FileOps { bool _cancel = false; void cancel() => _cancel = true; + /// 속도 제한 (KB/s, 0 = 제한 없음) + final int bandwidthKBps; + + /// 파일 하나를 다 복사할 때마다 (원본 경로) + final void Function(String source)? onFileDone; + + FileOps({this.bandwidthKBps = 0, this.onFileDone}); + + final _clock = Stopwatch(); + int _sent = 0; + + /// 속도 제한: 보낸 양이 허용량보다 앞서면 그만큼 쉰다 + Future _throttle(int bytes) async { + if (bandwidthKBps <= 0) return; + if (!_clock.isRunning) _clock.start(); + _sent += bytes; + final wantMs = _sent * 1000 ~/ (bandwidthKBps * 1024); + final ahead = wantMs - _clock.elapsedMilliseconds; + if (ahead > 5) await Future.delayed(Duration(milliseconds: ahead)); + } + void _check() { if (_cancel) throw const FileOpCancelled(); } @@ -136,6 +157,7 @@ class FileOps { out.add(chunk); done += chunk.length; onProgress?.call(f.path, done, total); + await _throttle(chunk.length); } } finally { await out.close(); @@ -151,6 +173,7 @@ class FileOps { try { await File(target).setLastModified(await f.lastModified()); } catch (_) {} + onFileDone?.call(f.path); } Future copyAny(String src, String target) async { @@ -204,6 +227,59 @@ class FileOps { return made; } + /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. + /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. + Future mirror(String srcDir, String dstDir, {bool delete = false}) async { + var n = 0; + Future walk(String src, String dst) async { + _check(); + await Directory(dst).create(recursive: true); + final names = {}; + await for (final e in Directory(src).list(followLinks: false).handleError((_) {})) { + final name = p.basename(e.path); + names.add(name); + final target = p.join(dst, name); + if (e is Directory) { + await walk(e.path, target); + } else if (e is File) { + final st = await e.stat(); + final t = File(target); + final same = await t.exists() && + (await t.length()) == st.size && + ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); + if (same) continue; + final tmp = '$target.jjsync'; + final out = File(tmp).openWrite(); + try { + await for (final chunk in e.openRead()) { + _check(); + out.add(chunk); + await _throttle(chunk.length); + } + } finally { + await out.close(); + } + await File(tmp).rename(target); + try { + await File(target).setLastModified(st.modified); + } catch (_) {} + n++; + onFileDone?.call(e.path); + } + } + if (delete) { + await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { + if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync')) { + await e.delete(recursive: true); + } + } + } + } + + await walk(srcDir, dstDir); + return n; + } + Future delete(List paths) async { for (final s in paths) { _check(); diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart new file mode 100644 index 0000000..5782dad --- /dev/null +++ b/lib/core/sync_tools.dart @@ -0,0 +1,221 @@ +import 'package:path/path.dart' as p; + +/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) +/// +/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기. +/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일) +/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만) +enum CopyMethod { + builtin('현재 방식'), + rsync('rsync'), + robocopy('robocopy'); + + final String label; + const CopyMethod(this.label); + + static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin); +} + +const defaultRsyncOptions = '-avPog'; +const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + +/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로) +List splitOptions(String s) { + final out = []; + final cur = StringBuffer(); + String? quote; + var has = false; + for (final ch in s.split('')) { + if (quote != null) { + if (ch == quote) { + quote = null; + } else { + cur.write(ch); + } + } else if (ch == '"' || ch == "'") { + quote = ch; + has = true; + } else if (ch.trim().isEmpty) { + if (cur.isNotEmpty || has) out.add(cur.toString()); + cur.clear(); + has = false; + } else { + cur.write(ch); + } + } + if (cur.isNotEmpty || has) out.add(cur.toString()); + return out; +} + +/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). +/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. +String toCygwinPath(String path, {bool windows = true}) { + if (!windows) return path; + final s = path.replaceAll('\\', '/'); + final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s); + if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}'; + return s; // //server/share 는 그대로 읽는다 +} + +/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/ +/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과). +List rsyncArgs({ + required String options, + required List sources, + required String dest, + int bandwidthKBps = 0, + bool move = false, + bool windows = true, +}) { + final opts = splitOptions(options); + return [ + ...opts, + if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', + if (move && !opts.contains('--remove-source-files')) '--remove-source-files', + for (final s in sources) toCygwinPath(_noTrailingSlash(s), windows: windows), + '${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/', + ]; +} + +String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; + +/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). +/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] +/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) +/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다. +List> robocopyRuns({ + required String options, + required List folders, + required List files, + required String dest, + int bandwidthKBps = 0, + bool move = false, +}) { + final opts = splitOptions(options); + final extra = [ + if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', + if (move && !opts.any((o) => o.toUpperCase().startsWith('/MOV'))) '/MOVE', + ]; + final runs = >[ + for (final f in folders) [f, p.join(dest, p.basename(f)), ...opts, ...extra], + ]; + final byDir = >{}; + for (final f in files) { + byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f)); + } + // 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다 + final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList(); + final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList(); + byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra])); + return runs; +} + +/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초 +int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000); + +/// rsync · robocopy 의 성공 종료 코드 +bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐 +bool robocopyOk(int code) => code >= 0 && code < 8; + +/// rsync -v 출력에서 "다 보낸 파일" 줄을 골라낸다 (-P 의 \r 진행 줄 · 머리글 · 요약 · 폴더 줄은 뺌). +/// 결과: 원본 기준 상대 경로 (예: "sub/inner.txt") +class RsyncOutput { + final _buf = StringBuffer(); + + /// 지금 파일의 진행률 (-P 의 " 1,234 45% 1.2MB/s" 줄) + double? currentPercent; + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trimRight(); + if (line.isEmpty) continue; + if (line.startsWith(' ')) { + final m = RegExp(r'\s(\d{1,3})%\s').firstMatch(line); + if (m != null) currentPercent = int.parse(m[1]!) / 100; + continue; + } + if (line.endsWith('/')) continue; // 폴더 + if (line.startsWith('sending ') || + line.startsWith('receiving ') || + line.startsWith('sent ') || + line.startsWith('total size') || + line.startsWith('created directory') || + line.startsWith('deleting ') || + line.startsWith('rsync:') || + line.startsWith('rsync error') || + line.contains(' -> ')) { + continue; + } + currentPercent = null; + done.add(line); + } + return done; + } +} + +/// robocopy 출력에서 파일 줄 ("New File", "Newer", "Older", "Changed" … 뒤의 크기 · 이름) 을 센다 +class RobocopyOutput { + final _buf = StringBuffer(); + double? currentPercent; + + static final _file = RegExp( + r'^\s*(New File|Newer|Older|Changed|Same|Tweaked|Modified|\*EXTRA File|새 파일|최신|이전|변경됨|같음)\s+[\d.]+\s*[kmgt]?\s+(.+)$', + caseSensitive: false); + + List feed(String chunk) { + _buf.write(chunk); + final text = _buf.toString(); + final cut = text.lastIndexOf(RegExp(r'[\r\n]')); + if (cut < 0) return const []; + _buf + ..clear() + ..write(text.substring(cut + 1)); + final done = []; + for (final raw in text.substring(0, cut).split(RegExp(r'[\r\n]'))) { + final line = raw.trim(); + final pct = RegExp(r'^(\d{1,3}(?:\.\d)?)%$').firstMatch(line); + if (pct != null) { + currentPercent = double.parse(pct[1]!) / 100; + continue; + } + final m = _file.firstMatch(raw); + if (m != null && !m[1]!.toLowerCase().startsWith('*extra')) { + currentPercent = null; + done.add(m[2]!.trim()); + } + } + return done; + } +} + +/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증) +const msys2Repo = 'https://repo.msys2.org/msys/x86_64/'; +const msys2RsyncPackages = <(String, String)>[ + ('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'), + ('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'), + ('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'), + ('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'), + ('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'), + ('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'), + ('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'), + ('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'), + ('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'), + ('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'), + ('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'), + ('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'), +]; + +/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만) +const msys2RsyncFiles = { + 'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll', + 'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll', + 'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll', +}; diff --git a/lib/main.dart b/lib/main.dart index 925074d..ce6850b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; import 'app/app_controller.dart'; +import 'app/live_sync.dart'; import 'app/bookmarks_controller.dart'; import 'app/download_manager.dart'; import 'app/settings.dart'; @@ -90,6 +91,8 @@ Future main(List args) async { controller.note(trf('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). ' '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.', [hm(crashed.$1), hm(crashed.$2)])); } await controller.init(); + // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 + LiveSync(controller).start(); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); @@ -371,6 +374,8 @@ Future runAndroid(String dataDir) async { final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log'); controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); await controller.init(); + // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안 + LiveSync(controller).start(); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); diff --git a/lib/platform/windows/rsync_installer.dart b/lib/platform/windows/rsync_installer.dart new file mode 100644 index 0000000..cc2359e --- /dev/null +++ b/lib/platform/windows/rsync_installer.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../core/sync_tools.dart'; + +/// Windows 용 rsync: 처음 쓸 때 MSYS2 공식 저장소에서 패키지를 받아 (SHA256 고정 검증) +/// 설정 폴더의 rsync\ 에 rsync.exe 와 필요한 DLL 만 풀어 둔다 (앱 업데이트로 지워지지 않게). +class RsyncInstaller { + /// 시험용으로 바꿀 수 있는 설치 폴더 + static String? dirOverride; + + static Future dir() async => + dirOverride ?? p.join((await getApplicationSupportDirectory()).path, 'rsync'); + + static Future installedPath() async { + final f = File(p.join(await dir(), 'rsync.exe')); + return await f.exists() ? f.path : null; + } + + /// 내려받기 · 검증 · 풀기. [onProgress] 0~1. 설치된 rsync.exe 경로를 돌려준다. + static Future install({void Function(double progress, String file)? onProgress}) async { + final target = await dir(); + final work = Directory(p.join(Directory.systemTemp.path, 'jj_rsync_${DateTime.now().millisecondsSinceEpoch}')); + await work.create(recursive: true); + final http = HttpClient()..userAgent = 'JJ_MKVMaker'; + try { + final total = msys2RsyncPackages.length; + for (var i = 0; i < total; i++) { + final (name, sha) = msys2RsyncPackages[i]; + onProgress?.call(i / total, name); + final f = File(p.join(work.path, name)); + final req = await http.getUrl(Uri.parse('$msys2Repo$name')); + final res = await req.close(); + if (res.statusCode != 200) throw HttpException('$name 을(를) 받지 못했습니다 (${res.statusCode})'); + await res.pipe(f.openWrite()); + final got = (await sha256.bind(f.openRead()).first).toString(); + if (got != sha) throw StateError('$name 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.'); + final t = await Process.run('tar', ['-xf', f.path, '-C', work.path, 'usr/bin']); + if (t.exitCode != 0) throw ProcessException('tar', [f.path], '${t.stderr}', t.exitCode); + } + final bin = Directory(p.join(work.path, 'usr', 'bin')); + await Directory(target).create(recursive: true); + for (final n in msys2RsyncFiles) { + final src = File(p.join(bin.path, n)); + if (!await src.exists()) throw StateError('패키지에 $n 이 없습니다'); + await src.copy(p.join(target, n)); + } + final exe = p.join(target, 'rsync.exe'); + final v = await Process.run(exe, ['--version']); + if (v.exitCode != 0) throw ProcessException(exe, ['--version'], '${v.stderr}', v.exitCode); + onProgress?.call(1, ''); + return exe; + } finally { + http.close(force: true); + try { + await work.delete(recursive: true); + } catch (_) {} + } + } + + /// 내려받은 rsync 지우기 + static Future uninstall() async { + final d = Directory(await dir()); + if (await d.exists()) await d.delete(recursive: true); + } +} diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart new file mode 100644 index 0000000..25ea991 --- /dev/null +++ b/lib/ui/copy_sync_settings.dart @@ -0,0 +1,300 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../app/app_controller.dart'; +import '../app/live_sync.dart'; +import '../app/settings.dart'; +import '../core/sync_tools.dart'; +import '../l10n/tr.dart'; +import '../platform/windows/rsync_installer.dart'; +import 'folder_picker.dart'; +import 'rsync_setup.dart'; +import 'theme.dart'; + +/// 환경 설정 > 파일 탐색기 의 복사 · 이동 · 동기화 부분: +/// 파일 / 폴더 복사 방법 (현재 방식 · rsync · robocopy), 옵션, 여러 개 실행 방식, 속도 제한, rsync 가져오기, 실시간 동기화. +class CopySyncSettings extends StatefulWidget { + final AppController c; + const CopySyncSettings({super.key, required this.c}); + + @override + State createState() => _CopySyncSettingsState(); +} + +class _CopySyncSettingsState extends State { + AppController get c => widget.c; + String? _rsync; // 쓸 수 있는 rsync 경로 + bool _checking = true; + + @override + void initState() { + super.initState(); + _check(); + } + + Future _check() async { + final r = await rsyncExecutable(c.settings); + if (mounted) { + setState(() { + _rsync = r; + _checking = false; + }); + } + } + + List> _methods() => [ + for (final m in CopyMethod.values) + if (copyMethodAvailable(m, c.settings)) DropdownMenuItem(value: m.name, child: Text(tr(m.label))), + ]; + + String _valid(String v) => copyMethodAvailable(CopyMethod.of(v), c.settings) ? v : 'builtin'; + + /// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값) + Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => ListTile( + title: Text(title), + subtitle: Text(hint), + trailing: SizedBox( + width: 380, + child: TextFormField( + key: ValueKey('$title|$value' == '$title|$def' ? '$title-def' : title), + initialValue: value, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: trf('기본값으로 ({0})', [def]), + icon: const Icon(Icons.restart_alt, size: 18), + onPressed: () async { + await c.updateSettings((x) => set(x, def)); + setState(() {}); + }, + ), + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (v) => c.updateSettings((x) => set(x, v.trim())), + ), + ), + ); + + @override + Widget build(BuildContext context) { + final s = c.settings; + final desk = Platform.isWindows; + final rsyncUsable = copyMethodAvailable(CopyMethod.rsync, s); + return Column(children: [ + const Divider(height: 1), + ListTile( + title: Text(tr('파일 복사 · 이동 방법')), + subtitle: Text(tr('파일만 골랐을 때')), + trailing: DropdownButton( + value: _valid(s.copyMethodFile), + items: _methods(), + onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!), + ), + ), + ListTile( + title: Text(tr('폴더 복사 · 이동 방법')), + subtitle: Text(tr('폴더가 들어 있을 때')), + trailing: DropdownButton( + value: _valid(s.copyMethodFolder), + items: _methods(), + onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!), + ), + ), + if (rsyncUsable) + _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임'), + s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), + if (desk) + _options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions, + defaultRobocopyOptions, (x, v) => x.robocopyOptions = v), + ListTile( + title: Text(tr('여러 개를 골랐을 때')), + subtitle: Text(tr('rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)')), + trailing: DropdownButton( + value: s.copyRunMode, + items: [ + DropdownMenuItem(value: 'each', child: Text(tr('항목마다 따로 (기본)'))), + DropdownMenuItem(value: 'once', child: Text(tr('한 번에'))), + ], + onChanged: (v) => c.updateSettings((x) => x.copyRunMode = v!), + ), + ), + ListTile( + title: Text(tr('속도 제한 (KB/s)')), + subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. ' + '옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)')), + trailing: SizedBox( + width: 140, + child: TextFormField( + initialValue: '${s.copyBandwidthKBps}', + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: 'KB/s'), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: (v) => c.updateSettings((x) => x.copyBandwidthKBps = int.tryParse(v) ?? 0), + ), + ), + ), + ListTile( + title: Text(tr('rsync 가져오기')), + subtitle: Text(_checking + ? tr('확인 중…') + : _rsync != null + ? trf('사용: {0}', [_rsync!]) + : s.rsyncSource == 'download' + ? (desk ? tr('아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)') : tr('Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.')) + : tr('지정한 파일이 없습니다')), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (desk && s.rsyncSource == 'download' && !_checking) + _rsync == null + ? TextButton( + onPressed: () async { + await installRsyncWithDialog(context); + await _check(); + }, + child: Text(tr('내려받기')), + ) + : IconButton( + tooltip: tr('내려받은 rsync 지우기'), + icon: const Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () async { + await RsyncInstaller.uninstall(); + await _check(); + }, + ), + const SizedBox(width: 8), + DropdownButton( + value: s.rsyncSource, + items: [ + DropdownMenuItem(value: 'download', child: Text(desk ? tr('처음 쓸 때 내려받기 (기본)') : tr('내려받기 (Windows 만)'))), + DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))), + ], + onChanged: (v) async { + await c.updateSettings((x) => x.rsyncSource = v!); + await _check(); + }, + ), + ]), + ), + if (s.rsyncSource == 'custom') + ListTile( + title: Text(tr('rsync 실행 파일')), + subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath), + trailing: TextButton( + onPressed: () async { + final r = await FilePicker.pickFiles(dialogTitle: tr('rsync 실행 파일')); + final path = r.isEmpty ? null : r.single.path; + if (path == null) return; + await c.updateSettings((x) => x.rsyncPath = path); + await _check(); + }, + child: Text(tr('고르기')), + ), + ), + _LiveSyncTile(c: c), + ]); + } +} + +/// 실시간 동기화 (lsyncd 처럼): 폴더 쌍 목록 · 추가 · 켜기 / 끄기 · 지금 맞추기 · 지우기 +class _LiveSyncTile extends StatelessWidget { + final AppController c; + const _LiveSyncTile({required this.c}); + + Future _add(BuildContext context) async { + final src = await pickFolder(context, tr('실시간 동기화: 원본 폴더')); + if (src == null || !context.mounted) return; + final dst = await pickFolder(context, tr('실시간 동기화: 대상 폴더')); + if (dst == null || !context.mounted) return; + var method = 'builtin'; + var delete = false; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('실시간 동기화 추가')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('$src\n→ $dst', style: const TextStyle(fontSize: 13)), + const SizedBox(height: 12), + Wrap(spacing: 6, children: [ + for (final m in CopyMethod.values) + if (copyMethodAvailable(m, c.settings)) + ChoiceChip(label: Text(tr(m.label)), selected: method == m.name, onSelected: (_) => set(() => method = m.name)), + ]), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: delete, + onChanged: (v) => set(() => delete = v ?? false), + title: Text(tr('원본에 없는 것을 대상에서 지우기')), + subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('추가'))), + ], + ), + ), + ); + if (ok != true) return; + await c.updateSettings((x) => x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src, dst, method: method, delete: delete)]); + } + + @override + Widget build(BuildContext context) { + final live = LiveSync.instance; + final pairs = c.settings.liveSyncPairs; + Widget body() => Column(children: [ + ListTile( + title: Text(tr('실시간 동기화 (lsyncd 처럼)')), + subtitle: Text(Platform.isWindows + ? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.') + : trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])), + trailing: TextButton.icon( + onPressed: () => _add(context), + icon: const Icon(Icons.add, size: 18), + label: Text(tr('추가')), + ), + ), + for (var i = 0; i < pairs.length; i++) + Padding( + padding: const EdgeInsets.only(left: 16), + child: ListTile( + dense: true, + leading: Icon(Icons.sync, color: pairs[i].enabled ? JjColors.accent : JjColors.textDim), + title: Text('${pairs[i].source} → ${pairs[i].target}', maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text([ + tr(CopyMethod.of(pairs[i].method).label), + if (pairs[i].delete) tr('지우기 포함'), + if (live?.isRunning(pairs[i]) ?? false) tr('맞추는 중…'), + if (live?.status[LiveSync.keyOf(pairs[i])] case final st?) + '${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}', + ].join(' · ')), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('지금 맞추기'), + icon: const Icon(Icons.sync, size: 18), + onPressed: live == null ? null : () => live.syncNow(pairs[i]), + ), + Switch( + value: pairs[i].enabled, + onChanged: (v) => c.updateSettings( + (x) => x.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]), + ), + IconButton( + tooltip: tr('지우기'), + icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), + onPressed: () => c.updateSettings( + (x) => x.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) if (k != i) pairs[k]]), + ), + ]), + ), + ), + ]); + return live == null ? body() : ListenableBuilder(listenable: live, builder: (_, _) => body()); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 879acdf..ec879a5 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -9,12 +9,16 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; +import '../app/live_sync.dart'; +import '../app/transfer_job.dart'; +import '../core/sync_tools.dart'; import '../core/file_ops.dart'; import '../core/playlist.dart' show isVideoFile; import '../platform/android/android_storage.dart'; import 'app_actions.dart'; import 'explorer_look.dart'; import 'player_page.dart'; +import 'rsync_setup.dart'; import 'theme.dart'; import '../l10n/tr.dart'; @@ -712,17 +716,48 @@ class _ExplorerPageState extends State { if (done && move && mounted) setState(() => _clip = []); } - /// [dest] 로 복사 · 이동 (확인 · 진행 창 · 취소). 끝까지 했으면 true. + /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) + TransferJob? _job; + + /// 고른 것에 맞는 방법: 폴더가 하나라도 있으면 "폴더 복사 방법", 파일만이면 "파일 복사 방법" + CopyMethod _methodFor(List sources) { + final s = c.settings; + final hasDir = sources.any(FileSystemEntity.isDirectorySync); + final m = CopyMethod.of(hasDir ? s.copyMethodFolder : s.copyMethodFile); + return copyMethodAvailable(m, s) ? m : CopyMethod.builtin; + } + + /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) + Future _ensureRsync() async { + final s = c.settings; + final have = await rsyncExecutable(s); + if (have != null) return have; + if (!mounted) return null; + if (s.rsyncSource == 'custom' || !Platform.isWindows) { + _snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); + return null; + } + return installRsyncWithDialog(context); + } + + /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. Future _runTransfer(_Pane pane, List sources, String dest, {required bool move}) async { if (sources.isEmpty) { _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return false; } + if (_job != null && !_job!.finished) { + _snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.')); + return false; + } + final method = _methodFor(sources); final go = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(move ? tr('이동') : tr('복사')), - content: Text(trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])), + content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])}' + '\n\n${trf('방법: {0}', [tr(method.label)])}' + '${c.settings.copyBandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [c.settings.copyBandwidthKBps])}' : ''}'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), @@ -730,39 +765,28 @@ class _ExplorerPageState extends State { ), ); if (go != true || !mounted) return false; - final ops = FileOps(); - final progress = ValueNotifier<(String, double)>(('', 0)); - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: Text(move ? tr('옮기는 중') : tr('복사하는 중')), - content: ValueListenableBuilder<(String, double)>( - valueListenable: progress, - builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(p.basename(v.$1), maxLines: 1, overflow: TextOverflow.ellipsis), - const SizedBox(height: 10), - LinearProgressIndicator(value: v.$2 <= 0 ? null : v.$2), - const SizedBox(height: 6), - Text('${(v.$2 * 100).round()}%', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - ), - actions: [TextButton(onPressed: ops.cancel, child: Text(tr('취소')))], - ), - ); - Object? error; - var made = []; - try { - void onP(String f, int done, int total) => progress.value = (f, total == 0 ? 0 : done / total); - made = move ? await ops.move(sources, dest, onProgress: onP) : await ops.copy(sources, dest, onProgress: onP); - } on FileOpCancelled { - error = tr('취소했습니다.'); - } catch (e) { - error = e; + String? rsync; + if (method == CopyMethod.rsync) { + rsync = await _ensureRsync(); + if (rsync == null || !mounted) return false; } - if (mounted) Navigator.of(context).pop(); + final s = c.settings; + final job = TransferJob( + sources: sources, + dest: dest, + move: move, + method: method, + options: method == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions, + once: s.copyRunMode == 'once', + bandwidthKBps: s.copyBandwidthKBps, + rsyncExe: rsync, + ); + setState(() => _job = job); + await job.run(); + final error = job.error; + if (!mounted) return false; pane.marked.clear(); - if (_selecting && mounted) _setSelecting(false); + if (_selecting) _setSelecting(false); await _refreshAll([dest, ...sources.map(p.dirname)]); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { @@ -772,12 +796,101 @@ class _ExplorerPageState extends State { } x.changed(); } - _snack(error != null - ? trf('끝나지 못했습니다: {0}', [error]) - : trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); + _snack(error is FileOpCancelled + ? tr('취소했습니다.') + : error != null + ? trf('끝나지 못했습니다: {0}', [error]) + : trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); + // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 + await Future.delayed(const Duration(milliseconds: 1200)); + if (mounted && _job == job) setState(() => _job = null); return error == null; } + /// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) + Widget _transferPanel() { + final job = _job; + return AnimatedSlide( + offset: job == null ? const Offset(0, 1.2) : Offset.zero, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: job == null + ? const SizedBox(height: 0) + : ListenableBuilder( + listenable: job, + builder: (context, _) { + String pct(double v) => '${(v * 100).round()}%'; + final cur = job.index < job.total ? job.index : job.total - 1; + final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)]; + final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)]; + final title = job.finished + ? (job.error == null ? tr('완료') : tr('멈춤')) + : job.move + ? tr('옮기는 중') + : tr('복사하는 중'); + Widget line(String label, double value, String right) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(children: [ + SizedBox( + width: 300, + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), + ), + const SizedBox(width: 10), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 8, + value: job.counting ? null : value, + // 빈 곳과 찬 곳이 분명히 다르게 + color: JjColors.accent, + backgroundColor: JjColors.border, + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 150, + child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ); + return Material( + elevation: 12, + color: JjColors.panel, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 12, 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent), + const SizedBox(width: 8), + Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + Expanded( + child: Text(job.dest, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), + ]), + line( + trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), + job.overall, + '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', + ), + line( + job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]), + job.current, + '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', + ), + ]), + ), + ); + }, + ), + ); + } + Future _askName(String title, String initial) { final ctl = TextEditingController(text: initial); final dot = initial.lastIndexOf('.'); @@ -1045,7 +1158,13 @@ class _ExplorerPageState extends State { listenable: c, builder: (context, _) => Column(children: [ _topBar(), - Expanded(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), + Expanded( + child: Stack(children: [ + Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), + // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 + Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), + ]), + ), ]), ), ), @@ -1395,8 +1514,10 @@ class _ExplorerPageState extends State { tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked, - size: look.rich ? 20 : 16, color: marked ? JjColors.accent : JjColors.textDim), + // ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게 + icon: Icon(marked ? Icons.check_circle : Icons.check_circle_outline, + size: look.rich ? 22 : 18, + color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), onPressed: () { setState(() => _active = _panes.indexOf(pane)); _toggleMark(pane, e); diff --git a/lib/ui/rsync_setup.dart b/lib/ui/rsync_setup.dart new file mode 100644 index 0000000..82979b3 --- /dev/null +++ b/lib/ui/rsync_setup.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../l10n/tr.dart'; +import '../platform/windows/rsync_installer.dart'; +import 'theme.dart'; + +/// Windows: rsync 내려받기 (MSYS2 공식 저장소 · 약 8MB) 를 묻고, 받는 동안 진행 창을 보인다. +/// 설치된 rsync.exe 경로 (취소 · 실패면 null). +Future installRsyncWithDialog(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('rsync 내려받기')), + content: Text(tr('rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n' + '설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?')), + 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 null; + final progress = ValueNotifier<(double, String)>((0, '')); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(tr('rsync 받는 중')), + content: ValueListenableBuilder<(double, String)>( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(v.$2, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + LinearProgressIndicator(value: v.$1), + ]), + ), + ), + ); + try { + final exe = await RsyncInstaller.install(onProgress: (x, f) => progress.value = (x, f)); + if (context.mounted) Navigator.of(context).pop(); + return exe; + } catch (e) { + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('rsync 를 받지 못했습니다: {0}', [e])))); + } + return null; + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 52370cd..d34074c 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -22,6 +22,7 @@ import 'language_settings.dart'; import 'app_actions.dart'; import 'cef_setup.dart'; import 'cleanup_dialog.dart'; +import 'copy_sync_settings.dart'; import 'explorer_look.dart' show ExplorerStyle; import 'theme.dart'; import '../l10n/tr.dart'; @@ -595,6 +596,8 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), ), ), + // 복사 · 이동 방법 (현재 방식 · rsync · robocopy) · 속도 제한 · 실시간 동기화 + CopySyncSettings(c: c), ]), _group('download', Icons.download_outlined, tr('다운로드'), [ if (desk) diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart new file mode 100644 index 0000000..0f59e1a --- /dev/null +++ b/test/sync_tools_test.dart @@ -0,0 +1,202 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/app/transfer_job.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/sync_tools.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/platform/windows/rsync_installer.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('명령 만들기', () { + test('옵션 나누기 (따옴표 묶음)', () { + expect(splitOptions('-avPog --exclude "a b" \'c d\''), ['-avPog', '--exclude', 'a b', 'c d']); + expect(splitOptions(' '), isEmpty); + }); + + test('Windows 경로 → MSYS2 rsync 경로', () { + expect(toCygwinPath(r'C:\Users\x\한글'), '/cygdrive/c/Users/x/한글'); + expect(toCygwinPath(r'D:\'), '/cygdrive/d/'); + expect(toCygwinPath(r'\\server\share\a'), '//server/share/a'); + expect(toCygwinPath('/storage/emulated/0', windows: false), '/storage/emulated/0'); + }); + + test('rsync 인수: 옵션 · 속도 제한 · 이동 · 원본 (끝 / 없음) · 대상/', () { + expect( + rsyncArgs(options: '-avPog', sources: [r'C:\a\src\', r'C:\a\f.mkv'], dest: r'E:\dst', bandwidthKBps: 500, move: true), + ['-avPog', '--bwlimit=500', '--remove-source-files', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']); + // 옵션 칸에 직접 넣은 속도 제한이 우선 + expect(rsyncArgs(options: '-a --bwlimit=10', sources: ['/x'], dest: '/y', bandwidthKBps: 500, windows: false), + ['-a', '--bwlimit=10', '/x', '/y/']); + }); + + test('robocopy: 폴더마다 · 같은 폴더 파일은 한 번에 · /IPG · 이동', () { + final runs = robocopyRuns( + options: '/E /R:2', + folders: [r'C:\a\movies'], + files: [r'C:\b\1.mkv', r'C:\b\2.mkv', r'C:\c\3.mkv'], + dest: r'E:\dst', + bandwidthKBps: 640, + move: true, + ); + expect(runs[0], [r'C:\a\movies', p.join(r'E:\dst', 'movies'), '/E', '/R:2', '/IPG:100', '/MOVE']); + expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV']); // 파일만: /E 빼고 /MOV + expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']); + expect(robocopyIpg(0), 0); + }); + + test('rsync 출력 읽기: 파일 줄만 (폴더 · 진행 · 요약 줄 뺌)', () { + final o = RsyncOutput(); + // 실제 rsync 3.5.1 -avPog 출력 (\r 진행 줄 포함) + expect(o.feed('sending incremental file list\nsrc/\nsrc/a.txt\n'), ['src/a.txt']); + expect(o.feed(' 3 100% 0.00kB/s 0:00:00\r 3 100% (xfr#1, to-chk=2/4)\nsrc/sub/\nsrc/sub/b'), + isEmpty); + expect(o.currentPercent, 1.0); + expect(o.feed('.txt\n\nsent 244 bytes received 66 bytes 620.00 bytes/sec\ntotal size is 5 speedup is 0.02\n'), + ['src/sub/b.txt']); + }); + + test('robocopy 출력 읽기: New File 줄 · % 줄', () { + final o = RobocopyOutput(); + expect(o.feed('\n\t New File \t\t 3\tC:\\t\\src\\a.txt\r\n45% \r\n'), [r'C:\t\src\a.txt']); + expect(o.currentPercent, 0.45); + expect(o.feed('100% \n\t Newer \t\t 200\tC:\\t\\src\\b.txt\n\t*EXTRA File \t\t 1\tC:\\x\n'), [r'C:\t\src\b.txt']); + }); + }); + + group('실제 복사', () { + late Directory tmp; + late String src, dst; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sync_'); + src = p.join(tmp.path, 'src'); + dst = p.join(tmp.path, 'dst'); + for (final (rel, text) in [('a.txt', 'aa'), ('sub/b.txt', 'bbb'), ('한글 폴더/동영상 1.txt', 'ccc')]) { + File(p.join(src, rel)) + ..createSync(recursive: true) + ..writeAsStringSync(text); + } + File(p.join(tmp.path, 'single.txt')).writeAsStringSync('s'); + Directory(dst).createSync(); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + void expectCopied() { + expect(File(p.join(dst, 'src', 'a.txt')).readAsStringSync(), 'aa'); + expect(File(p.join(dst, 'src', 'sub', 'b.txt')).readAsStringSync(), 'bbb'); + expect(File(p.join(dst, 'src', '한글 폴더', '동영상 1.txt')).readAsStringSync(), 'ccc'); + expect(File(p.join(dst, 'single.txt')).readAsStringSync(), 's'); + } + + test('현재 방식: 진행 (항목 2개 · 폴더 안 파일 3개) · 이동', () async { + final job = TransferJob(sources: [src, p.join(tmp.path, 'single.txt')], dest: dst, move: false); + final seen = <(int, int, int)>[]; + job.addListener(() => seen.add((job.index, job.filesDone[0], job.filesTotal[0]))); + await job.run(); + expect(job.error, isNull); + expectCopied(); + expect(job.filesTotal, [3, 1]); + expect(job.overall, 1); + expect(seen.where((x) => x.$1 == 0).map((x) => x.$2).toSet(), containsAll([1, 2, 3])); // 파일마다 알림 + final mv = TransferJob(sources: [p.join(dst, 'single.txt')], dest: src, move: true); + await mv.run(); + expect(File(p.join(src, 'single.txt')).existsSync(), isTrue); + expect(File(p.join(dst, 'single.txt')).existsSync(), isFalse); + }); + + test('현재 방식 속도 제한: 64KB 를 32KB/s 로 → 1.5초 넘게', () async { + File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(64 * 1024, 7)); + final sw = Stopwatch()..start(); + await FileOps(bandwidthKBps: 32).copy([p.join(src, 'big.bin')], dst); + expect(sw.elapsedMilliseconds, greaterThan(1500)); + }); + + test('동기화 (mirror): 바뀐 것만 · 지우기', () async { + final ops = FileOps(); + expect(await ops.mirror(src, dst), 3); + expect(await ops.mirror(src, dst), 0); // 바뀐 것 없음 + File(p.join(src, 'a.txt')).writeAsStringSync('changed!'); + File(p.join(dst, 'extra.txt')).writeAsStringSync('x'); + expect(await ops.mirror(src, dst), 1); + expect(File(p.join(dst, 'extra.txt')).existsSync(), isTrue); + await ops.mirror(src, dst, delete: true); + expect(File(p.join(dst, 'extra.txt')).existsSync(), isFalse); + expect(File(p.join(dst, 'a.txt')).readAsStringSync(), 'changed!'); + }); + + test('robocopy (Windows): 폴더 · 파일 · 한글 이름 · 진행', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 만'); + final job = TransferJob( + sources: [src, p.join(tmp.path, 'single.txt')], + dest: dst, + move: false, + method: CopyMethod.robocopy, + options: defaultRobocopyOptions, + bandwidthKBps: 1000); + await job.run(); + expect(job.error, isNull); + expectCopied(); + expect(job.filesDone, [3, 1]); + }); + + test('rsync (Windows, 실제 내려받기 · JJ_NET_TESTS=1): -avPog · 따로 / 한 번에 · 이동', () async { + if (!Platform.isWindows || Platform.environment['JJ_NET_TESTS'] != '1') { + return markTestSkipped('인터넷 테스트 꺼짐'); + } + RsyncInstaller.dirOverride = p.join(tmp.path, 'rsync'); + addTearDown(() => RsyncInstaller.dirOverride = null); + final exe = await RsyncInstaller.install(); + for (final once in [false, true]) { + Directory(dst).deleteSync(recursive: true); + Directory(dst).createSync(); + final job = TransferJob( + sources: [src, p.join(tmp.path, 'single.txt')], + dest: dst, + move: false, + method: CopyMethod.rsync, + options: defaultRsyncOptions, + once: once, + bandwidthKBps: 1000, + rsyncExe: exe); + await job.run(); + expect(job.error, isNull, reason: job.log.join('\n')); + expectCopied(); + expect(job.filesDone, [3, 1], reason: 'once=$once'); + } + // 이동: 원본 파일 · 빈 폴더가 사라진다 + final mv = TransferJob( + sources: [src], dest: p.join(tmp.path, 'moved'), move: true, method: CopyMethod.rsync, options: '-a', rsyncExe: exe); + Directory(p.join(tmp.path, 'moved')).createSync(); + await mv.run(); + expect(mv.error, isNull, reason: mv.log.join('\n')); + expect(Directory(src).existsSync(), isFalse); + expect(File(p.join(tmp.path, 'moved', 'src', 'sub', 'b.txt')).existsSync(), isTrue); + }); + + test('실시간 동기화: 지금 맞추기 (현재 방식) · 상태', () async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final pair = LiveSyncPair(src, p.join(tmp.path, 'mirror')); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + await live.syncNow(pair); + expect(File(p.join(tmp.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bbb'); + expect(live.status[LiveSync.keyOf(pair)]!.$2, contains('3')); + // 대상이 원본 안이면 거부 + c.settings.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))]; + live.start(); + expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.first)]!.$2, contains('안에')); + live.dispose(); + // 설정 저장 · 읽기 + final back = AppSettings.fromJson(c.settings.toJson()); + expect(back.liveSyncPairs.single.target, p.join(src, 'in')); + expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource], + ['builtin', '-avPog', 'each', 'download']); + }); + }); +}