파일 탐색기: 복사 · 이동 방법 (현재 방식 · 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)", "(설명 없음)": "(no description)",
"+ 여기에 새 줄": "+ New line here", "+ 여기에 새 줄": "+ New line here",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)", ".{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", "0 으로": "Reset to 0",
"10초 뒤로 (←)": "Back 10 seconds (←)", "10초 뒤로 (←)": "Back 10 seconds (←)",
"10초 앞으로 (→)": "Forward 10 seconds (→)", "10초 앞으로 (→)": "Forward 10 seconds (→)",
@ -59,6 +60,7 @@
"API 키": "API key", "API 키": "API key",
"API 키 (필수, 무료)": "API key (required, free)", "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.)", "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\")", "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}", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
"Chrome (내려받기)": "Chrome (download)", "Chrome (내려받기)": "Chrome (download)",
@ -145,6 +147,18 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
"quit 시작": "quit started", "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} 받는 중": "Downloading v{0}",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}",
"yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", "yt-dlp 를 새 버전으로 바꿨습니다": "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} 브라우저의 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} 순으로 정렬{1}": "Sort by {0}{1}",
"{0} 아래에 만듭니다": "Created under {0}", "{0} 아래에 만듭니다": "Created under {0}",
"{0} 안의 파일": "Files in {0}",
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
@ -176,6 +191,7 @@
"{0}개": "{0}", "{0}개": "{0}",
"{0}개 · {1}": "{0} · {1}", "{0}개 · {1}": "{0} · {1}",
"{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 · 주소 {1}개": "{0} · {1} links",
"{0}개 맞춤": "{0} synced",
"{0}개 선택": "{0} selected", "{0}개 선택": "{0} selected",
"{0}개 표시함": "{0} marked", "{0}개 표시함": "{0} marked",
"{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 {1}": "{0} items {1}",
@ -189,6 +205,7 @@
"{0}시간 {1}분": "{0} h {1} min", "{0}시간 {1}분": "{0} h {1} min",
"{0}줄": "{0} lines", "{0}줄": "{0} lines",
"{0}초": "{0} s", "{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} 또는 트레이 아이콘으로 다시 열기)\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", "… 외 {0}개": "… and {0} more",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 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}": "Open output folder: {0}",
"결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})", "결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})",
"계속 편집": "Keep editing", "계속 편집": "Keep editing",
"고르기": "Choose",
"고르지 않음": "Not chosen",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)",
"고지 문서": "Notices", "고지 문서": "Notices",
"고화질": "High quality", "고화질": "High quality",
@ -243,6 +262,7 @@
"기본 화면 크기": "Default display size", "기본 화면 크기": "Default display size",
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
"기본값": "Default", "기본값": "Default",
"기본값으로 ({0})": "Default ({0})",
"기타 즐겨찾기": "Other bookmarks", "기타 즐겨찾기": "Other bookmarks",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
"길이 {0}": "Length {0}", "길이 {0}": "Length {0}",
@ -263,11 +283,13 @@
"날짜": "Date", "날짜": "Date",
"남은 시간 {0}": "{0} left", "남은 시간 {0}": "{0} left",
"내려받기": "Download", "내려받기": "Download",
"내려받기 (Windows 만)": "Download (Windows only)",
"내려받기 실패 ({0})": "Download failed ({0})", "내려받기 실패 ({0})": "Download failed ({0})",
"내려받기 실패 ({0}): {1}": "Download failed ({0}): {1}", "내려받기 실패 ({0}): {1}": "Download failed ({0}): {1}",
"내려받는 중": "Downloading", "내려받는 중": "Downloading",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)",
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine", "내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
"내려받은 rsync 지우기": "Delete downloaded rsync",
"내보냈습니다: {0}": "Exported: {0}", "내보냈습니다: {0}": "Exported: {0}",
"내역": "History", "내역": "History",
"내역 (최근 폴더)": "History (recent folders)", "내역 (최근 폴더)": "History (recent folders)",
@ -382,7 +404,10 @@
"맞추기 (검은 여백)": "Fit (black bars)", "맞추기 (검은 여백)": "Fit (black bars)",
"맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)",
"맞추기 (흐린 배경)": "Fit (blurred background)", "맞추기 (흐린 배경)": "Fit (blurred background)",
"맞추는 중…": "Syncing…",
"맞춤": "Synced",
"매번 고르기": "Ask every time", "매번 고르기": "Ask every time",
"멈춤": "Stopped",
"모델 내려받는 중 {0}%": "Downloading model {0}%", "모델 내려받는 중 {0}%": "Downloading model {0}%",
"모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})", "모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델 파일이 잘렸습니다 (위치 {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.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
"받음 {0}": "Received {0}", "받음 {0}": "Received {0}",
"밝기": "Brightness", "밝기": "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)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {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", "백그라운드로": "To background",
"백그라운드로 계속 (기본)": "Keep running in the background (default)", "백그라운드로 계속 (기본)": "Keep running in the background (default)",
@ -463,6 +489,7 @@
"비어 있음": "Empty", "비어 있음": "Empty",
"사용 안 함": "Off", "사용 안 함": "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", "사용 안 함. \"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.", "사용자가 취소했습니다.": "Cancelled by the user.",
"사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.", "사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.",
"삭제": "Delete", "삭제": "Delete",
@ -510,6 +537,8 @@
"세로 4:5": "Portrait 4:5", "세로 4:5": "Portrait 4:5",
"세로 9:16": "Portrait 9:16", "세로 9:16": "Portrait 9:16",
"세로 고정": "Portrait lock", "세로 고정": "Portrait lock",
"속도 제한 (KB/s)": "Speed limit (KB/s)",
"속도 제한 {0} KB/s": "Speed limit {0} KB/s",
"수정": "Edit", "수정": "Edit",
"수정 · 이동": "Edit · move", "수정 · 이동": "Edit · move",
"수정한 날짜": "Date modified", "수정한 날짜": "Date modified",
@ -532,6 +561,12 @@
"시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)",
"시작할 때마다 설정 창 보기": "Show the setup window at every start", "시작할 때마다 설정 창 보기": "Show the setup window at every start",
"시즌": "Season", "시즌": "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", "실패": "Failed",
"실패: {0}": "Failed: {0}", "실패: {0}": "Failed: {0}",
"실패: {0}\n{1}": "Failed: {0}\n{1}", "실패: {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])", "아이콘 · 목록 모양: 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}", "아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "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}", "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
"압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", "압축을 풀 수 없습니다: {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", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "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", "앱 아이콘": "App icon",
"앱 안 브라우저 (권장)": "In-app browser (recommended)", "앱 안 브라우저 (권장)": "In-app browser (recommended)",
@ -569,6 +606,7 @@
"없음": "None", "없음": "None",
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", "여기에 놓으면 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)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
"연도": "Year", "연도": "Year",
"열기": "Open", "열기": "Open",
@ -580,6 +618,8 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "Re-encodes the video (takes a long time, audio unchanged){0}", "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){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}.", "영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "Recognizes the video's speech and translates it into the chosen languages. All processing happens on this {0}.",
"영어": "English", "영어": "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)", "예: 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}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})",
@ -609,6 +649,10 @@
"원본 비율": "Original aspect", "원본 비율": "Original aspect",
"원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)",
"원본 크기": "Original size", "원본 크기": "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", "원본의 {0}배": "{0}× original",
"원어 (영상 속 언어)": "Source language (spoken in the video)", "원어 (영상 속 언어)": "Source language (spoken in the video)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
@ -773,6 +817,7 @@
"저장했습니다: {0}": "Saved: {0}", "저장했습니다: {0}": "Saved: {0}",
"저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)", "저장했습니다: {0} (MKV 에 반영됨)": "Saved: {0} (applied to MKV)",
"적용": "Apply", "적용": "Apply",
"전체 {0}개 중 {1}번째": "Item {1} of {0}",
"전체 만들기": "Make all", "전체 만들기": "Make all",
"전체 목록": "Full list", "전체 목록": "Full list",
"전체 선택": "Select all", "전체 선택": "Select all",
@ -825,18 +870,23 @@
"즐겨찾기 추가…": "Add bookmark…", "즐겨찾기 추가…": "Add bookmark…",
"즐겨찾기 추가됨": "Bookmark added", "즐겨찾기 추가됨": "Bookmark added",
"즐겨찾기 표시줄": "Bookmarks bar", "즐겨찾기 표시줄": "Bookmarks bar",
"지금 맞추기": "Sync now",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
"지금 설치": "Install now", "지금 설치": "Install now",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
"지금: {0}": "Now: {0}", "지금: {0}": "Now: {0}",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
"지난 작업 기록": "Old job logs", "지난 작업 기록": "Old job logs",
"지우기": "Delete",
"지우기 포함": "with delete",
"지우지 못했습니다: {0}": "Could not delete: {0}", "지우지 못했습니다: {0}": "Could not delete: {0}",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "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})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})",
"지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}",
"지정한 파일이 없습니다": "The specified file does not exist",
"직접 입력": "Custom", "직접 입력": "Custom",
"직접 입력…": "Custom…", "직접 입력…": "Custom…",
"직접 지정": "Specify",
"진행 중인 작업 없음": "No jobs in progress", "진행 중인 작업 없음": "No jobs in progress",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?",
"창": "Panes", "창": "Panes",
@ -854,6 +904,7 @@
"채도": "Saturation", "채도": "Saturation",
"채우기 (가운데 잘라냄)": "Fill (crop the center)", "채우기 (가운데 잘라냄)": "Fill (crop the center)",
"처음 사용 시 내려받기 {0}": "Downloads {0} on first use", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use",
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
"처음 아이콘 (파랑)": "Original icon (blue)", "처음 아이콘 (파랑)": "Original icon (blue)",
"첫 줄 추가": "Add first line", "첫 줄 추가": "Add first line",
"청각장애인용": "Hearing impaired", "청각장애인용": "Hearing impaired",
@ -891,7 +942,10 @@
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
"트레이 정리": "Clean up tray", "트레이 정리": "Clean up tray",
"파일": "File", "파일": "File",
"파일 {0}/{1}": "Files {0}/{1}",
"파일 관리자에서 보기": "Show in file manager", "파일 관리자에서 보기": "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", "파일 앱의 \"다음으로 열기\" · 공유에서 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\".", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "The file location is unknown, so it will only be played. To make an MKV, choose it with \"Add videos\".",
"파일 이름": "File name", "파일 이름": "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)", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "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", "파일 폴더": "File folder",
"파일 확인 중…": "Checking files…", "파일 확인 중…": "Checking files…",
"파일만 골랐을 때": "When only files are selected",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"팝업 끄기": "Close pop-up", "팝업 끄기": "Close pop-up",
"팝업 보기": "Pop-up view", "팝업 보기": "Pop-up view",
@ -915,6 +970,7 @@
"폴더": "Folder", "폴더": "Folder",
"폴더 + 파일 목록": "Folders + file list", "폴더 + 파일 목록": "Folders + file list",
"폴더 만들기": "New folder", "폴더 만들기": "New folder",
"폴더 복사 · 이동 방법": "Folder copy · move method",
"폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)",
"폴더 선택": "Choose folder", "폴더 선택": "Choose folder",
"폴더 수정": "Edit folder", "폴더 수정": "Edit folder",
@ -923,6 +979,7 @@
"폴더 이름 바꾸기 · 이동": "Rename · move folder", "폴더 이름 바꾸기 · 이동": "Rename · move folder",
"폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "The folder name has characters that cannot be used (\\ / : * ? \" < > |)", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "The folder name has characters that cannot be used (\\ / : * ? \" < > |)",
"폴더: {0}": "Folder: {0}", "폴더: {0}": "Folder: {0}",
"폴더가 들어 있을 때": "When folders are included",
"폴더가 아닙니다: {0}": "Not a folder: {0}", "폴더가 아닙니다: {0}": "Not a folder: {0}",
"폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
"폴란드어": "Polish", "폴란드어": "Polish",
@ -951,13 +1008,16 @@
"하위 폴더가 없습니다": "No subfolders", "하위 폴더가 없습니다": "No subfolders",
"한 번: 바로 실행": "Single: open right away", "한 번: 바로 실행": "Single: open right away",
"한 번: 선택 · 두 번: 실행 (기본)": "Single: select · Double: open (default)", "한 번: 선택 · 두 번: 실행 (기본)": "Single: select · Double: open (default)",
"한 번에": "All at once",
"한 창": "One pane", "한 창": "One pane",
"한국어": "Korean", "한국어": "Korean",
"한글": "Hangul", "한글": "Hangul",
"항목마다 따로 (기본)": "Each item separately (default)",
"해제": "Unset", "해제": "Unset",
"허용": "Allow", "허용": "Allow",
"허용했으면 다시 확인": "Check again after allowing", "허용했으면 다시 확인": "Check again after allowing",
"헝가리어": "Hungarian", "헝가리어": "Hungarian",
"현재 방식": "Built-in",
"현재 페이지": "Current page", "현재 페이지": "Current page",
"현재 페이지 추가": "Add current page", "현재 페이지 추가": "Add current page",
"형식 오류": "Format error", "형식 오류": "Format error",
@ -979,6 +1039,7 @@
"화질": "Quality", "화질": "Quality",
"확인": "OK", "확인": "OK",
"확인 · 압축 푸는 중…": "Verifying · extracting…", "확인 · 압축 푸는 중…": "Verifying · extracting…",
"확인 중…": "Checking…",
"확장자": "Ext", "확장자": "Ext",
"확장자별 재생 프로그램": "Player per file extension", "확장자별 재생 프로그램": "Player per file extension",
"환경 설정": "Settings", "환경 설정": "Settings",

View File

@ -34,6 +34,7 @@
"(설명 없음)": "(説明なし)", "(설명 없음)": "(説明なし)",
"+ 여기에 새 줄": "+ ここに新しい行", "+ 여기에 새 줄": "+ ここに新しい行",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
"0 으로": "0 に戻す", "0 으로": "0 に戻す",
"10초 뒤로 (←)": "10秒戻る(←)", "10초 뒤로 (←)": "10秒戻る(←)",
"10초 앞으로 (→)": "10秒進む(→)", "10초 앞으로 (→)": "10秒進む(→)",
@ -59,6 +60,7 @@
"API 키": "API キー", "API 키": "API キー",
"API 키 (필수, 무료)": "API キー(必須・無料)", "API 키 (필수, 무료)": "API キー(必須・無料)",
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)",
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。",
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)",
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
"Chrome (내려받기)": "Chrome(ダウンロード)", "Chrome (내려받기)": "Chrome(ダウンロード)",
@ -145,6 +147,18 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
"quit 시작": "終了開始", "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} 받는 중": "v{0} をダウンロード中",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}",
"yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました",
@ -163,6 +177,7 @@
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)", "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)",
"{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}", "{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}",
"{0} 아래에 만듭니다": "{0} の下に作成します", "{0} 아래에 만듭니다": "{0} の下に作成します",
"{0} 안의 파일": "{0} 内のファイル",
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
@ -176,6 +191,7 @@
"{0}개": "{0}個", "{0}개": "{0}個",
"{0}개 · {1}": "{0}個・{1}", "{0}개 · {1}": "{0}個・{1}",
"{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 · 주소 {1}개": "{0}個・URL {1}個",
"{0}개 맞춤": "{0} 件を同期",
"{0}개 선택": "{0}個選択", "{0}개 선택": "{0}個選択",
"{0}개 표시함": "{0} 件をマーク", "{0}개 표시함": "{0} 件をマーク",
"{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 {1}": "{0} 件の項目を{1}",
@ -189,6 +205,7 @@
"{0}시간 {1}분": "{0}時間 {1}分", "{0}시간 {1}분": "{0}時間 {1}分",
"{0}줄": "{0}行", "{0}줄": "{0}行",
"{0}초": "{0}秒", "{0}초": "{0}秒",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。",
"· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します",
"… 외 {0}개": "… ほか {0} 件", "… 외 {0}개": "… ほか {0} 件",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。",
@ -222,6 +239,8 @@
"결과 폴더 열기: {0}": "出力フォルダーを開く: {0}", "결과 폴더 열기: {0}": "出力フォルダーを開く: {0}",
"결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})", "결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})",
"계속 편집": "編集を続ける", "계속 편집": "編集を続ける",
"고르기": "選ぶ",
"고르지 않음": "未選択",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)",
"고지 문서": "告知文書", "고지 문서": "告知文書",
"고화질": "高画質", "고화질": "高画質",
@ -243,6 +262,7 @@
"기본 화면 크기": "既定の画面サイズ", "기본 화면 크기": "既定の画面サイズ",
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
"기본값": "既定値", "기본값": "既定値",
"기본값으로 ({0})": "既定値に戻す ({0})",
"기타 즐겨찾기": "その他のブックマーク", "기타 즐겨찾기": "その他のブックマーク",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
"길이 {0}": "長さ {0}", "길이 {0}": "長さ {0}",
@ -263,11 +283,13 @@
"날짜": "日付", "날짜": "日付",
"남은 시간 {0}": "残り時間 {0}", "남은 시간 {0}": "残り時間 {0}",
"내려받기": "ダウンロード", "내려받기": "ダウンロード",
"내려받기 (Windows 만)": "ダウンロード (Windows のみ)",
"내려받기 실패 ({0})": "ダウンロード失敗({0})", "내려받기 실패 ({0})": "ダウンロード失敗({0})",
"내려받기 실패 ({0}): {1}": "ダウンロード失敗({0}): {1}", "내려받기 실패 ({0}): {1}": "ダウンロード失敗({0}): {1}",
"내려받는 중": "ダウンロード中", "내려받는 중": "ダウンロード中",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)",
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
"내려받은 rsync 지우기": "ダウンロードした rsync を削除",
"내보냈습니다: {0}": "書き出しました: {0}", "내보냈습니다: {0}": "書き出しました: {0}",
"내역": "履歴", "내역": "履歴",
"내역 (최근 폴더)": "履歴 (最近のフォルダー)", "내역 (최근 폴더)": "履歴 (最近のフォルダー)",
@ -382,7 +404,10 @@
"맞추기 (검은 여백)": "フィット(黒い余白)", "맞추기 (검은 여백)": "フィット(黒い余白)",
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
"맞추기 (흐린 배경)": "フィット(ぼかし背景)", "맞추기 (흐린 배경)": "フィット(ぼかし背景)",
"맞추는 중…": "同期中…",
"맞춤": "同期済み",
"매번 고르기": "毎回選ぶ", "매번 고르기": "毎回選ぶ",
"멈춤": "停止",
"모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%", "모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%",
"모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})", "모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})",
@ -423,6 +448,7 @@
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
"받음 {0}": "受信 {0}", "받음 {0}": "受信 {0}",
"밝기": "明るさ", "밝기": "明るさ",
"방법: {0}": "方法: {0}",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {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」エラーが出たら設定してください", "사용 안 함. \"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", "세로 4:5": "縦 4:5",
"세로 9:16": "縦 9:16", "세로 9:16": "縦 9:16",
"세로 고정": "縦向き固定", "세로 고정": "縦向き固定",
"속도 제한 (KB/s)": "速度制限 (KB/s)",
"속도 제한 {0} KB/s": "速度制限 {0} KB/s",
"수정": "編集", "수정": "編集",
"수정 · 이동": "編集・移動", "수정 · 이동": "編集・移動",
"수정한 날짜": "更新日時", "수정한 날짜": "更新日時",
@ -532,6 +561,12 @@
"시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)",
"시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示",
"시즌": "シーズン", "시즌": "シーズン",
"실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)",
"실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}",
"실시간 동기화 실패: {0} → {1}: {2}": "リアルタイム同期に失敗: {0} → {1}: {2}",
"실시간 동기화 추가": "リアルタイム同期を追加",
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
"실패": "失敗", "실패": "失敗",
"실패: {0}": "失敗: {0}", "실패: {0}": "失敗: {0}",
"실패: {0}\n{1}": "失敗: {0}\n{1}", "실패: {0}\n{1}": "失敗: {0}\n{1}",
@ -549,10 +584,12 @@
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
"아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}", "아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。",
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
"압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
"앱 아이콘": "アプリアイコン", "앱 아이콘": "アプリアイコン",
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
@ -569,6 +606,7 @@
"없음": "なし", "없음": "なし",
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"연도": "年", "연도": "年",
"열기": "開く", "열기": "開く",
@ -580,6 +618,8 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "映像を再エンコードします(時間がかかります、音声はそのまま){0}", "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "映像を再エンコードします(時間がかかります、音声はそのまま){0}",
"영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {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)", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)",
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})",
@ -609,6 +649,10 @@
"원본 비율": "元の比率", "원본 비율": "元の比率",
"원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)",
"원본 크기": "元のサイズ", "원본 크기": "元のサイズ",
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。",
"원본 폴더가 없습니다": "元フォルダーがありません",
"원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります",
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
"원본의 {0}배": "元の {0}倍", "원본의 {0}배": "元の {0}倍",
"원어 (영상 속 언어)": "原語(動画内の言語)", "원어 (영상 속 언어)": "原語(動画内の言語)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
@ -773,6 +817,7 @@
"저장했습니다: {0}": "保存しました: {0}", "저장했습니다: {0}": "保存しました: {0}",
"저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)", "저장했습니다: {0} (MKV 에 반영됨)": "保存しました: {0}(MKV に反映済み)",
"적용": "適用", "적용": "適用",
"전체 {0}개 중 {1}번째": "全 {0} 件中 {1} 件目",
"전체 만들기": "すべて作成", "전체 만들기": "すべて作成",
"전체 목록": "全体リスト", "전체 목록": "全体リスト",
"전체 선택": "すべて選択", "전체 선택": "すべて選択",
@ -825,18 +870,23 @@
"즐겨찾기 추가…": "ブックマークを追加…", "즐겨찾기 추가…": "ブックマークを追加…",
"즐겨찾기 추가됨": "ブックマークを追加しました", "즐겨찾기 추가됨": "ブックマークを追加しました",
"즐겨찾기 표시줄": "ブックマークバー", "즐겨찾기 표시줄": "ブックマークバー",
"지금 맞추기": "今すぐ同期",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
"지금 설치": "今すぐインストール", "지금 설치": "今すぐインストール",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
"지금: {0}": "現在: {0}", "지금: {0}": "現在: {0}",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
"지난 작업 기록": "過去の作業記録", "지난 작업 기록": "過去の作業記録",
"지우기": "削除",
"지우기 포함": "削除あり",
"지우지 못했습니다: {0}": "削除できませんでした: {0}", "지우지 못했습니다: {0}": "削除できませんでした: {0}",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})",
"지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}",
"지정한 파일이 없습니다": "指定したファイルがありません",
"직접 입력": "直接入力", "직접 입력": "直接入力",
"직접 입력…": "直接入力…", "직접 입력…": "直接入力…",
"직접 지정": "直接指定",
"진행 중인 작업 없음": "進行中の作業なし", "진행 중인 작업 없음": "進行中の作業なし",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?",
"창": "ペイン", "창": "ペイン",
@ -854,6 +904,7 @@
"채도": "彩度", "채도": "彩度",
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
"처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}",
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
"처음 아이콘 (파랑)": "最初のアイコン(青)", "처음 아이콘 (파랑)": "最初のアイコン(青)",
"첫 줄 추가": "最初の行を追加", "첫 줄 추가": "最初の行を追加",
"청각장애인용": "聴覚障害者用", "청각장애인용": "聴覚障害者用",
@ -891,7 +942,10 @@
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
"트레이 정리": "トレイの後片付け", "트레이 정리": "トレイの後片付け",
"파일": "ファイル", "파일": "ファイル",
"파일 {0}/{1}": "ファイル {0}/{1}",
"파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 관리자에서 보기": "ファイルマネージャーで表示",
"파일 복사 · 이동 방법": "ファイルのコピー · 移動方法",
"파일 세는 중…": "ファイルを数えています…",
"파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "ファイルアプリの「アプリで開く」・共有で JJ_MKVMaker を選んだとき", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "ファイルアプリの「アプリで開く」・共有で JJ_MKVMaker を選んだとき",
"파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "ファイルの場所がわからないため再生のみ行います。MKV を作成するには「動画を追加」で選んでください。", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "ファイルの場所がわからないため再生のみ行います。MKV を作成するには「動画を追加」で選んでください。",
"파일 이름": "ファイル名", "파일 이름": "ファイル名",
@ -900,6 +954,7 @@
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)",
"파일 폴더": "ファイル フォルダー", "파일 폴더": "ファイル フォルダー",
"파일 확인 중…": "ファイルを確認中…", "파일 확인 중…": "ファイルを確認中…",
"파일만 골랐을 때": "ファイルだけを選んだとき",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"팝업 끄기": "ポップアップを閉じる", "팝업 끄기": "ポップアップを閉じる",
"팝업 보기": "ポップアップ表示", "팝업 보기": "ポップアップ表示",
@ -915,6 +970,7 @@
"폴더": "フォルダー", "폴더": "フォルダー",
"폴더 + 파일 목록": "フォルダー + ファイル一覧", "폴더 + 파일 목록": "フォルダー + ファイル一覧",
"폴더 만들기": "フォルダーを作成", "폴더 만들기": "フォルダーを作成",
"폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法",
"폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)",
"폴더 선택": "フォルダーを選択", "폴더 선택": "フォルダーを選択",
"폴더 수정": "フォルダーを編集", "폴더 수정": "フォルダーを編集",
@ -923,6 +979,7 @@
"폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動", "폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動",
"폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "フォルダー名に使えない文字があります(\\ / : * ? \" < > |)", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "フォルダー名に使えない文字があります(\\ / : * ? \" < > |)",
"폴더: {0}": "フォルダー: {0}", "폴더: {0}": "フォルダー: {0}",
"폴더가 들어 있을 때": "フォルダーを含むとき",
"폴더가 아닙니다: {0}": "フォルダーではありません: {0}", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}",
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
"폴란드어": "ポーランド語", "폴란드어": "ポーランド語",
@ -951,13 +1008,16 @@
"하위 폴더가 없습니다": "サブフォルダーがありません", "하위 폴더가 없습니다": "サブフォルダーがありません",
"한 번: 바로 실행": "1 回: すぐに開く", "한 번: 바로 실행": "1 回: すぐに開く",
"한 번: 선택 · 두 번: 실행 (기본)": "1 回: 選択 · 2 回: 開く (既定)", "한 번: 선택 · 두 번: 실행 (기본)": "1 回: 選択 · 2 回: 開く (既定)",
"한 번에": "まとめて 1 回",
"한 창": "1 ペイン", "한 창": "1 ペイン",
"한국어": "韓国語", "한국어": "韓国語",
"한글": "ハングル", "한글": "ハングル",
"항목마다 따로 (기본)": "項目ごとに (既定)",
"해제": "解除", "해제": "解除",
"허용": "許可", "허용": "許可",
"허용했으면 다시 확인": "許可したらもう一度確認", "허용했으면 다시 확인": "許可したらもう一度確認",
"헝가리어": "ハンガリー語", "헝가리어": "ハンガリー語",
"현재 방식": "現在の方式",
"현재 페이지": "現在のページ", "현재 페이지": "現在のページ",
"현재 페이지 추가": "現在のページを追加", "현재 페이지 추가": "現在のページを追加",
"형식 오류": "形式エラー", "형식 오류": "形式エラー",
@ -979,6 +1039,7 @@
"화질": "画質", "화질": "画質",
"확인": "OK", "확인": "OK",
"확인 · 압축 푸는 중…": "確認・展開中…", "확인 · 압축 푸는 중…": "確認・展開中…",
"확인 중…": "確認中…",
"확장자": "拡張子", "확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
"환경 설정": "環境設定", "환경 설정": "環境設定",

View File

@ -34,6 +34,7 @@
"(설명 없음)": "(无说明)", "(설명 없음)": "(无说明)",
"+ 여기에 새 줄": "+ 在此新增一行", "+ 여기에 새 줄": "+ 在此新增一行",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
"0 으로": "归零", "0 으로": "归零",
"10초 뒤로 (←)": "后退 10 秒(←)", "10초 뒤로 (←)": "后退 10 秒(←)",
"10초 앞으로 (→)": "前进 10 秒(→)", "10초 앞으로 (→)": "前进 10 秒(→)",
@ -59,6 +60,7 @@
"API 키": "API 密钥", "API 키": "API 密钥",
"API 키 (필수, 무료)": "API 密钥(必填,免费)", "API 키 (필수, 무료)": "API 密钥(必填,免费)",
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)",
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。",
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)",
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
"Chrome (내려받기)": "Chrome(下载)", "Chrome (내려받기)": "Chrome(下载)",
@ -145,6 +147,18 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
"quit 시작": "开始退出", "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} 받는 중": "正在下载 v{0}",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}",
"yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本",
@ -163,6 +177,7 @@
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)", "{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)",
"{0} 순으로 정렬{1}": "按{0}排序{1}", "{0} 순으로 정렬{1}": "按{0}排序{1}",
"{0} 아래에 만듭니다": "在 {0} 下创建", "{0} 아래에 만듭니다": "在 {0} 下创建",
"{0} 안의 파일": "{0} 中的文件",
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
@ -176,6 +191,7 @@
"{0}개": "{0} 个", "{0}개": "{0} 个",
"{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1}": "{0} 个 · {1}",
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
"{0}개 맞춤": "已同步 {0} 个",
"{0}개 선택": "已选 {0} 个", "{0}개 선택": "已选 {0} 个",
"{0}개 표시함": "已标记 {0} 个", "{0}개 표시함": "已标记 {0} 个",
"{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 {1}": "{0} 个项目{1}",
@ -189,6 +205,7 @@
"{0}시간 {1}분": "{0} 小时 {1} 分", "{0}시간 {1}분": "{0} 小时 {1} 分",
"{0}줄": "{0} 行", "{0}줄": "{0} 行",
"{0}초": "{0} 秒", "{0}초": "{0} 秒",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。",
"· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序",
"… 외 {0}개": "… 另外 {0} 个", "… 외 {0}개": "… 另外 {0} 个",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。",
@ -222,6 +239,8 @@
"결과 폴더 열기: {0}": "打开输出文件夹: {0}", "결과 폴더 열기: {0}": "打开输出文件夹: {0}",
"결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})", "결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})",
"계속 편집": "继续编辑", "계속 편집": "继续编辑",
"고르기": "选择",
"고르지 않음": "未选择",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)",
"고지 문서": "声明文件", "고지 문서": "声明文件",
"고화질": "高画质", "고화질": "高画质",
@ -243,6 +262,7 @@
"기본 화면 크기": "默认显示大小", "기본 화면 크기": "默认显示大小",
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A", "기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
"기본값": "默认值", "기본값": "默认值",
"기본값으로 ({0})": "恢复默认 ({0})",
"기타 즐겨찾기": "其他书签", "기타 즐겨찾기": "其他书签",
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
"길이 {0}": "时长 {0}", "길이 {0}": "时长 {0}",
@ -263,11 +283,13 @@
"날짜": "日期", "날짜": "日期",
"남은 시간 {0}": "剩余时间 {0}", "남은 시간 {0}": "剩余时间 {0}",
"내려받기": "下载", "내려받기": "下载",
"내려받기 (Windows 만)": "下载 (仅 Windows)",
"내려받기 실패 ({0})": "下载失败({0})", "내려받기 실패 ({0})": "下载失败({0})",
"내려받기 실패 ({0}): {1}": "下载失败({0}): {1}", "내려받기 실패 ({0}): {1}": "下载失败({0}): {1}",
"내려받는 중": "正在下载", "내려받는 중": "正在下载",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)",
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
"내려받은 rsync 지우기": "删除已下载的 rsync",
"내보냈습니다: {0}": "已导出: {0}", "내보냈습니다: {0}": "已导出: {0}",
"내역": "历史", "내역": "历史",
"내역 (최근 폴더)": "历史 (最近的文件夹)", "내역 (최근 폴더)": "历史 (最近的文件夹)",
@ -382,7 +404,10 @@
"맞추기 (검은 여백)": "适应(黑边)", "맞추기 (검은 여백)": "适应(黑边)",
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)", "맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
"맞추기 (흐린 배경)": "适应(模糊背景)", "맞추기 (흐린 배경)": "适应(模糊背景)",
"맞추는 중…": "正在同步…",
"맞춤": "已同步",
"매번 고르기": "每次选择", "매번 고르기": "每次选择",
"멈춤": "已停止",
"모델 내려받는 중 {0}%": "正在下载模型 {0}%", "모델 내려받는 중 {0}%": "正在下载模型 {0}%",
"모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})", "모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})",
"모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})",
@ -423,6 +448,7 @@
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
"받음 {0}": "已接收 {0}", "받음 {0}": "已接收 {0}",
"밝기": "亮度", "밝기": "亮度",
"방법: {0}": "方式: {0}",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {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”错误请设置", "사용 안 함. \"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", "세로 4:5": "竖屏 4:5",
"세로 9:16": "竖屏 9:16", "세로 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}": "失败: {0}",
"실패: {0}\n{1}": "失败: {0}\n{1}", "실패: {0}\n{1}": "失败: {0}\n{1}",
@ -549,10 +584,12 @@
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
"아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}", "아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}",
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。", "아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。",
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
"압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
"앱 아이콘": "应用图标", "앱 아이콘": "应用图标",
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)", "앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
@ -569,6 +606,7 @@
"없음": "无", "없음": "无",
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"연도": "年份", "연도": "年份",
"열기": "打开", "열기": "打开",
@ -580,6 +618,8 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "重新编码视频(耗时较长,音频不变){0}", "영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "重新编码视频(耗时较长,音频不变){0}",
"영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {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)", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)",
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})", "오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})",
@ -609,6 +649,10 @@
"원본 비율": "原始比例", "원본 비율": "原始比例",
"원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)",
"원본 크기": "原始尺寸", "원본 크기": "原始尺寸",
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。",
"원본 폴더가 없습니다": "源文件夹不存在",
"원본과 대상이 서로 안에 있습니다": "源与目标互相包含",
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
"원본의 {0}배": "原始的 {0} 倍", "원본의 {0}배": "原始的 {0} 倍",
"원어 (영상 속 언어)": "原语言(视频中的语言)", "원어 (영상 속 언어)": "原语言(视频中的语言)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
@ -773,6 +817,7 @@
"저장했습니다: {0}": "已保存: {0}", "저장했습니다: {0}": "已保存: {0}",
"저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)", "저장했습니다: {0} (MKV 에 반영됨)": "已保存: {0}(已应用到 MKV)",
"적용": "应用", "적용": "应用",
"전체 {0}개 중 {1}번째": "共 {0} 个中的第 {1} 个",
"전체 만들기": "全部制作", "전체 만들기": "全部制作",
"전체 목록": "完整列表", "전체 목록": "完整列表",
"전체 선택": "全选", "전체 선택": "全选",
@ -825,18 +870,23 @@
"즐겨찾기 추가…": "添加书签…", "즐겨찾기 추가…": "添加书签…",
"즐겨찾기 추가됨": "已添加书签", "즐겨찾기 추가됨": "已添加书签",
"즐겨찾기 표시줄": "书签栏", "즐겨찾기 표시줄": "书签栏",
"지금 맞추기": "立即同步",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
"지금 설치": "立即安装", "지금 설치": "立即安装",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
"지금: {0}": "当前: {0}", "지금: {0}": "当前: {0}",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
"지난 작업 기록": "旧任务记录", "지난 작업 기록": "旧任务记录",
"지우기": "删除",
"지우기 포함": "含删除",
"지우지 못했습니다: {0}": "无法删除: {0}", "지우지 못했습니다: {0}": "无法删除: {0}",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})",
"지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}",
"지정한 파일이 없습니다": "指定的文件不存在",
"직접 입력": "手动输入", "직접 입력": "手动输入",
"직접 입력…": "手动输入…", "직접 입력…": "手动输入…",
"직접 지정": "手动指定",
"진행 중인 작업 없음": "没有进行中的任务", "진행 중인 작업 없음": "没有进行中的任务",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?",
"창": "窗格", "창": "窗格",
@ -854,6 +904,7 @@
"채도": "饱和度", "채도": "饱和度",
"채우기 (가운데 잘라냄)": "填充(裁切中间)", "채우기 (가운데 잘라냄)": "填充(裁切中间)",
"처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}",
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
"처음 아이콘 (파랑)": "最初的图标(蓝色)", "처음 아이콘 (파랑)": "最初的图标(蓝色)",
"첫 줄 추가": "添加第一行", "첫 줄 추가": "添加第一行",
"청각장애인용": "听障字幕", "청각장애인용": "听障字幕",
@ -891,7 +942,10 @@
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
"트레이 정리": "清理托盘", "트레이 정리": "清理托盘",
"파일": "文件", "파일": "文件",
"파일 {0}/{1}": "文件 {0}/{1}",
"파일 관리자에서 보기": "在文件管理器中显示", "파일 관리자에서 보기": "在文件管理器中显示",
"파일 복사 · 이동 방법": "文件复制 · 移动方式",
"파일 세는 중…": "正在统计文件…",
"파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "在文件应用的“打开方式” · 分享中选择 JJ_MKVMaker 时", "파일 앱의 \"다음으로 열기\" · 공유에서 JJ_MKVMaker 를 골랐을 때": "在文件应用的“打开方式” · 分享中选择 JJ_MKVMaker 时",
"파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "无法确定文件位置,因此仅播放。要制作 MKV,请用“添加视频”选择。", "파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 \"동영상 추가\" 로 고르세요.": "无法确定文件位置,因此仅播放。要制作 MKV,请用“添加视频”选择。",
"파일 이름": "文件名", "파일 이름": "文件名",
@ -900,6 +954,7 @@
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)", "파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)",
"파일 폴더": "文件夹", "파일 폴더": "文件夹",
"파일 확인 중…": "正在检查文件…", "파일 확인 중…": "正在检查文件…",
"파일만 골랐을 때": "只选择文件时",
"파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}", "파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}",
"팝업 끄기": "关闭弹出窗口", "팝업 끄기": "关闭弹出窗口",
"팝업 보기": "弹出窗口观看", "팝업 보기": "弹出窗口观看",
@ -915,6 +970,7 @@
"폴더": "文件夹", "폴더": "文件夹",
"폴더 + 파일 목록": "文件夹 + 文件列表", "폴더 + 파일 목록": "文件夹 + 文件列表",
"폴더 만들기": "新建文件夹", "폴더 만들기": "新建文件夹",
"폴더 복사 · 이동 방법": "文件夹复制 · 移动方式",
"폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)",
"폴더 선택": "选择文件夹", "폴더 선택": "选择文件夹",
"폴더 수정": "编辑文件夹", "폴더 수정": "编辑文件夹",
@ -923,6 +979,7 @@
"폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹", "폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹",
"폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "文件夹名称中有不能使用的字符(\\ / : * ? \" < > |)", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "文件夹名称中有不能使用的字符(\\ / : * ? \" < > |)",
"폴더: {0}": "文件夹: {0}", "폴더: {0}": "文件夹: {0}",
"폴더가 들어 있을 때": "包含文件夹时",
"폴더가 아닙니다: {0}": "不是文件夹: {0}", "폴더가 아닙니다: {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); 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 에 저장) /// 환경 설정 (settings.json 에 저장)
class AppSettings { class AppSettings {
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
@ -158,6 +186,29 @@ class AppSettings {
bool explorerSortDesc = false; bool explorerSortDesc = false;
bool explorerShowHidden = 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 = []; List<String> explorerPaths = [];
@ -240,6 +291,16 @@ class AppSettings {
'explorerSortDesc': explorerSortDesc, 'explorerSortDesc': explorerSortDesc,
'explorerShowHidden': explorerShowHidden, 'explorerShowHidden': explorerShowHidden,
'explorerPaths': explorerPaths, '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, 'explorerHistory': explorerHistory,
'autoCheckUpdates': autoCheckUpdates, 'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck, 'lastUpdateCheck': lastUpdateCheck,
@ -264,6 +325,8 @@ class AppSettings {
'aiWhisper': aiWhisper, 'aiWhisper': aiWhisper,
}; };
static String _method(Object? v) => const ['rsync', 'robocopy'].contains(v) ? v as String : 'builtin';
/// 색 보정 값 (-100 ~ 100) /// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-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 ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false ..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..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'] ..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x']
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..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; bool _cancel = false;
void cancel() => _cancel = true; 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() { void _check() {
if (_cancel) throw const FileOpCancelled(); if (_cancel) throw const FileOpCancelled();
} }
@ -136,6 +157,7 @@ class FileOps {
out.add(chunk); out.add(chunk);
done += chunk.length; done += chunk.length;
onProgress?.call(f.path, done, total); onProgress?.call(f.path, done, total);
await _throttle(chunk.length);
} }
} finally { } finally {
await out.close(); await out.close();
@ -151,6 +173,7 @@ class FileOps {
try { try {
await File(target).setLastModified(await f.lastModified()); await File(target).setLastModified(await f.lastModified());
} catch (_) {} } catch (_) {}
onFileDone?.call(f.path);
} }
Future<void> copyAny(String src, String target) async { Future<void> copyAny(String src, String target) async {
@ -204,6 +227,59 @@ class FileOps {
return made; 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 { Future<void> delete(List<String> paths) async {
for (final s in paths) { for (final s in paths) {
_check(); _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 'package:window_manager/window_manager.dart';
import 'app/app_controller.dart'; import 'app/app_controller.dart';
import 'app/live_sync.dart';
import 'app/bookmarks_controller.dart'; import 'app/bookmarks_controller.dart';
import 'app/download_manager.dart'; import 'app/download_manager.dart';
import 'app/settings.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)])); controller.note(trf('⚠ 지난 실행이 정상적으로 끝나지 않았습니다 (시작 {0}, 마지막 확인 {1}). ' '그 전의 기록은 Logs 폴더의 app.log 에 있습니다.', [hm(crashed.$1), hm(crashed.$2)]));
} }
await controller.init(); await controller.init();
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안
LiveSync(controller).start();
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); 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'); final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log');
controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); controller.note(trf('── 시작 {0} (Android) ──', [appTitle]));
await controller.init(); await controller.init();
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안
LiveSync(controller).start();
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); 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 'package:path/path.dart' as p;
import '../app/app_controller.dart'; 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/file_ops.dart';
import '../core/playlist.dart' show isVideoFile; import '../core/playlist.dart' show isVideoFile;
import '../platform/android/android_storage.dart'; import '../platform/android/android_storage.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'explorer_look.dart'; import 'explorer_look.dart';
import 'player_page.dart'; import 'player_page.dart';
import 'rsync_setup.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -712,17 +716,48 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (done && move && mounted) setState(() => _clip = []); 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 { Future<bool> _runTransfer(_Pane pane, List<String> sources, String dest, {required bool move}) async {
if (sources.isEmpty) { if (sources.isEmpty) {
_snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return false; return false;
} }
if (_job != null && !_job!.finished) {
_snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.'));
return false;
}
final method = _methodFor(sources);
final go = await showDialog<bool>( final go = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(move ? tr('이동') : tr('복사')), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : 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; if (go != true || !mounted) return false;
final ops = FileOps(); String? rsync;
final progress = ValueNotifier<(String, double)>(('', 0)); if (method == CopyMethod.rsync) {
showDialog<void>( rsync = await _ensureRsync();
context: context, if (rsync == null || !mounted) return false;
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;
} }
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(); pane.marked.clear();
if (_selecting && mounted) _setSelecting(false); if (_selecting) _setSelecting(false);
await _refreshAll([dest, ...sources.map(p.dirname)]); await _refreshAll([dest, ...sources.map(p.dirname)]);
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
for (final x in _panes) { for (final x in _panes) {
@ -772,12 +796,101 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
x.changed(); x.changed();
} }
_snack(error != null _snack(error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error]) ? 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; 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) { Future<String?> _askName(String title, String initial) {
final ctl = TextEditingController(text: initial); final ctl = TextEditingController(text: initial);
final dot = initial.lastIndexOf('.'); final dot = initial.lastIndexOf('.');
@ -1045,7 +1158,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
listenable: c, listenable: c,
builder: (context, _) => Column(children: [ builder: (context, _) => Column(children: [
_topBar(), _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('표시 (여러 개 고르기)'), tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, 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: () { onPressed: () {
setState(() => _active = _panes.indexOf(pane)); setState(() => _active = _panes.indexOf(pane));
_toggleMark(pane, e); _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 'app_actions.dart';
import 'cef_setup.dart'; import 'cef_setup.dart';
import 'cleanup_dialog.dart'; import 'cleanup_dialog.dart';
import 'copy_sync_settings.dart';
import 'explorer_look.dart' show ExplorerStyle; import 'explorer_look.dart' show ExplorerStyle;
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -595,6 +596,8 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
), ),
), ),
// 복사 · 이동 방법 (현재 방식 · rsync · robocopy) · 속도 제한 · 실시간 동기화
CopySyncSettings(c: c),
]), ]),
_group('download', Icons.download_outlined, tr('다운로드'), [ _group('download', Icons.download_outlined, tr('다운로드'), [
if (desk) 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']);
});
});
}