파일 탐색기: 복사 · 이동 방법 (현재 방식 · 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
parent
626117995d
commit
7308f8ce1b
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
"확인 · 압축 푸는 중…": "確認・展開中…",
|
"확인 · 압축 푸는 중…": "確認・展開中…",
|
||||||
|
"확인 중…": "確認中…",
|
||||||
"확장자": "拡張子",
|
"확장자": "拡張子",
|
||||||
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
|
||||||
"환경 설정": "環境設定",
|
"환경 설정": "環境設定",
|
||||||
|
|
|
||||||
|
|
@ -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 @@
|
||||||
"화질": "画质",
|
"화질": "画质",
|
||||||
"확인": "确定",
|
"확인": "确定",
|
||||||
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
|
"확인 · 압축 푸는 중…": "正在校验 · 解压…",
|
||||||
|
"확인 중…": "正在检查…",
|
||||||
"확장자": "扩展名",
|
"확장자": "扩展名",
|
||||||
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
"확장자별 재생 프로그램": "按扩展名设置播放程序",
|
||||||
"환경 설정": "设置",
|
"환경 설정": "设置",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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? ?? ''
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue