파일 탐색기: 복사 · 이동 방법 (현재 방식 · rsync · robocopy), 속도 제한, 실시간 동기화, 아래 진행 막대

- 환경 설정 > 파일 탐색기: 파일 / 폴더 복사 방법, rsync · robocopy 옵션 (기본 -avPog · /E /COPY:DAT /DCOPY:T /R:2 /W:2,
  바꿀 수 있음), 여러 개를 고르면 항목마다 따로 (기본) / 한 번에, 속도 제한 KB/s (rsync --bwlimit · 현재 방식 앱 조절 · robocopy /IPG)
- rsync (Windows): 처음 쓸 때 MSYS2 공식 저장소에서 rsync 3.5.1 과 필요한 DLL 을 내려받기 (파일마다 SHA256 고정 검증),
  설정 폴더 rsync\ 에 둔다. 경로는 /cygdrive/c/... 로 바꿔 넘김. 또는 직접 지정 (Android 도 지정하면 사용)
- robocopy (Windows): 폴더마다 실행, 파일은 같은 폴더끼리, 출력에서 파일 수를 세어 진행 표시
- 실시간 동기화 (lsyncd 처럼): 원본 · 대상 폴더 쌍, Windows 는 폴더 감시로 바로, Android 는 30초마다, 방법 · 지우기 선택
- 복사 · 이동 진행: 아래에서 올라오는 막대 두 줄 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중), 끝나면 내려감
- 선택 모드의 ✔: 고르지 않은 것은 흐리게, 고른 것은 진하게
Android 태블릿 (Galaxy Tab S6 Lite) 에서 속도 제한 복사 · 진행 막대 확인. rsync 는 실제로 내려받아 시험 (JJ_NET_TESTS=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 10:38:09 +09:00
parent 626117995d
commit 7308f8ce1b
16 changed files with 1838 additions and 39 deletions

View File

@ -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",

View File

@ -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",
"확인 · 압축 푸는 중…": "確認・展開中…",
"확인 중…": "確認中…",
"확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
"환경 설정": "環境設定",

View File

@ -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 @@
"화질": "画质",
"확인": "确定",
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
"확인 중…": "正在检查…",
"확장자": "扩展名",
"확장자별 재생 프로그램": "按扩展名设置播放程序",
"환경 설정": "设置",

View File

@ -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<void>.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();
});
}

181
lib/app/live_sync.dart Normal file
View File

@ -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<String?> 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 = <String, StreamSubscription<FileSystemEvent>>{};
final _timers = <String, Timer>{};
final _pending = <String, Timer>{};
final _running = <String>{};
/// 쌍마다 마지막 결과 (화면 표시용): (시각, 글)
final status = <String, (DateTime, String)>{};
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<void> 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<int> _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<int> _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;
}
}

View File

@ -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<String, Object?> toJson() =>
{'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled};
factory LiveSyncPair.fromJson(Map<Object?, Object?> 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<LiveSyncPair> liveSyncPairs = [];
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
/// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창)
List<String> 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? ?? ''

259
lib/app/transfer_job.dart Normal file
View File

@ -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<String> 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<int> filesTotal = List.filled(sources.length, 0);
late final List<int> filesDone = List.filled(sources.length, 0);
/// 지금 파일의 진행률 (rsync -P · robocopy 가 알려 줄 때)
double? filePercent;
bool counting = true;
bool finished = false;
Object? error;
bool cancelled = false;
final made = <String>[];
/// 마지막 출력 몇 줄 (실패했을 때 보여 줌)
final log = <String>[];
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<int> 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<void> 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<void> _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<int> _start(String exe, List<String> 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<void> _runRsync() async {
final exe = rsyncExe;
if (exe == null || exe.isEmpty) throw StateError('rsync 실행 파일이 없습니다');
final windows = Platform.isWindows;
Future<void> one(List<int> 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<void> _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;
}
}

View File

@ -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<void> _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<void>.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<void> copyAny(String src, String target) async {
@ -204,6 +227,59 @@ class FileOps {
return made;
}
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다.
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
Future<int> mirror(String srcDir, String dstDir, {bool delete = false}) async {
var n = 0;
Future<void> walk(String src, String dst) async {
_check();
await Directory(dst).create(recursive: true);
final names = <String>{};
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<void> delete(List<String> paths) async {
for (final s in paths) {
_check();

221
lib/core/sync_tools.dart Normal file
View File

@ -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<String> splitOptions(String s) {
final out = <String>[];
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<String> rsyncArgs({
required String options,
required List<String> 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<List<String>> robocopyRuns({
required String options,
required List<String> folders,
required List<String> 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 = <List<String>>[
for (final f in folders) [f, p.join(dest, p.basename(f)), ...opts, ...extra],
];
final byDir = <String, List<String>>{};
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<String> 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 = <String>[];
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<String> 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 = <String>[];
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',
};

View File

@ -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<void> main(List<String> 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<void> 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);

View File

@ -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<String> dir() async =>
dirOverride ?? p.join((await getApplicationSupportDirectory()).path, 'rsync');
static Future<String?> 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<String> 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<void> uninstall() async {
final d = Directory(await dir());
if (await d.exists()) await d.delete(recursive: true);
}
}

View File

@ -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<CopySyncSettings> createState() => _CopySyncSettingsState();
}
class _CopySyncSettingsState extends State<CopySyncSettings> {
AppController get c => widget.c;
String? _rsync; // 쓸 수 있는 rsync 경로
bool _checking = true;
@override
void initState() {
super.initState();
_check();
}
Future<void> _check() async {
final r = await rsyncExecutable(c.settings);
if (mounted) {
setState(() {
_rsync = r;
_checking = false;
});
}
}
List<DropdownMenuItem<String>> _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<String>(
value: _valid(s.copyMethodFile),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!),
),
),
ListTile(
title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>(
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<String>(
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<String>(
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<void> _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<bool>(
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());
}
}

View File

@ -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<ExplorerPage> {
if (done && move && mounted) setState(() => _clip = []);
}
/// [dest] 로 복사 · 이동 (확인 · 진행 창 · 취소). 끝까지 했으면 true.
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
TransferJob? _job;
/// 고른 것에 맞는 방법: 폴더가 하나라도 있으면 "폴더 복사 방법", 파일만이면 "파일 복사 방법"
CopyMethod _methodFor(List<String> 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<String?> _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<bool> _runTransfer(_Pane pane, List<String> 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<bool>(
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<ExplorerPage> {
),
);
if (go != true || !mounted) return false;
final ops = FileOps();
final progress = ValueNotifier<(String, double)>(('', 0));
showDialog<void>(
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 = <String>[];
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<ExplorerPage> {
}
x.changed();
}
_snack(error != null
_snack(error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
: trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
: trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await Future<void>.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<String?> _askName(String title, String initial) {
final ctl = TextEditingController(text: initial);
final dot = initial.lastIndexOf('.');
@ -1045,7 +1158,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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);

50
lib/ui/rsync_setup.dart Normal file
View File

@ -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<String?> installRsyncWithDialog(BuildContext context) async {
final ok = await showDialog<bool>(
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<void>(
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;
}
}

View File

@ -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<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
),
),
// 복사 · 이동 방법 (현재 방식 · rsync · robocopy) · 속도 제한 · 실시간 동기화
CopySyncSettings(c: c),
]),
_group('download', Icons.download_outlined, tr('다운로드'), [
if (desk)

202
test/sync_tools_test.dart Normal file
View File

@ -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']);
});
});
}