WIP X-plore 비교 5-1 ③ 일괄 이름 바꾸기 - 음악 태그 · 글자 범위 (관리자 지시 10/21): 이름 틀에 [Artist] · [Title] · [Album] · [Track] (두 자리) · [Year] (창의 ♪ 메뉴로 넣기) - 이 기기의 MP3 (ID3v2.2 · 2.3 · 2.4, 없으면 ID3v1) · FLAC (Vorbis comment) · M4A (MP4 ilst) 의 앞부분만 읽는 작은 읽개 (새 패키지 없음 - 📜 걱정 없음), 원격 · 못 읽으면 빈칸, 이름에 못 쓰는 글자는 _. [N2-5] = 원래 이름의 2~5 번째 글자, [N3-] = 3 번째부터 (유니코드 한 글자씩) · 412 큰 글자에서 넘치지 않게 ♪ 는 작은 아이콘 · 시험 (태그 형식 4가지 · 한글 · 일본어 · 잘린 머리 · 범위 · 창) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 03:03:42 +09:00
parent f92f801e7a
commit 3444d84f35
7 changed files with 450 additions and 11 deletions

View File

@ -433,8 +433,8 @@
"ZIP 이 있는 폴더로": "To the ZIP's folder", "ZIP 이 있는 폴더로": "To the ZIP's folder",
"ZIP 이름": "ZIP name", "ZIP 이름": "ZIP name",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · [D] date · [T] time · extension kept e.g. Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept e.g. Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept e.g. Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [N2-5] its characters 2–5 · [C] counter · [D] date · [T] time · ♪ music tags · extension kept e.g. Show S01E[C]",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].",
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "Tap [Allow] to open Android settings. After you allow JJ_MKVMaker and come back, videos that couldn't be read are analyzed again automatically.", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "Tap [Allow] to open Android settings. After you allow JJ_MKVMaker and come back, videos that couldn't be read are analyzed again automatically.",
@ -656,6 +656,7 @@
"가로 고정": "Landscape only", "가로 고정": "Landscape only",
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)",
"가리기": "Hide", "가리기": "Hide",
"가수": "Artist",
"가입 → 프로필 → API consumers → New consumer": "Sign up → Profile → API consumers → New consumer", "가입 → 프로필 → API consumers → New consumer": "Sign up → Profile → API consumers → New consumer",
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "Finding the fastest processor (first time only): {0}", "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "Finding the fastest processor (first time only): {0}",
"가져오기": "Import", "가져오기": "Import",
@ -1815,6 +1816,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
"앞의 버튼 보기": "Previous buttons", "앞의 버튼 보기": "Previous buttons",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
"앨범": "Album",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "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",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.",
"앱 설정 전체 (처음 상태로)": "All app settings (back to initial)", "앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
@ -2065,6 +2067,7 @@
"음성인식 중 {0}%": "Recognizing speech {0}%", "음성인식 중 {0}%": "Recognizing speech {0}%",
"음소거 (M)": "Mute (M)", "음소거 (M)": "Mute (M)",
"음악": "Music", "음악": "Music",
"음악 태그 넣기": "Insert music tag",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "Check that this PC's network is a \"Private network\": Settings > Network & internet > Wi-Fi (or Ethernet) > the connected network > Network profile type = Private. Only use Private on home or trusted networks.", "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "Check that this PC's network is a \"Private network\": Settings > Network & internet > Wi-Fi (or Ethernet) > the connected network > Network profile type = Private. Only use Private on home or trusted networks.",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "This PC's secure storage could not be decrypted. Please enter your WebDAV and OpenSubtitles passwords once more. The file that could not be decrypted was kept, not deleted: {0}", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "This PC's secure storage could not be decrypted. Please enter your WebDAV and OpenSubtitles passwords once more. The file that could not be decrypted was kept, not deleted: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in", "이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in",
@ -2690,6 +2693,7 @@
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "Can't reach trackers or peers ({0} s)", "트래커 · 피어에 닿지 않습니다 ({0}초째)": "Can't reach trackers or peers ({0} s)",
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}", "트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
"트랙 {0}": "Track {0}", "트랙 {0}": "Track {0}",
"트랙 번호": "Track number",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
"트레이 정리": "Clean up tray", "트레이 정리": "Clean up tray",
"트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "Click the JJ_MKVMaker icon in the tray to open it.", "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "Click the JJ_MKVMaker icon in the tray to open it.",
@ -2856,6 +2860,7 @@
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).",
"항목 {0}/{1}": "Items {0}/{1}", "항목 {0}/{1}": "Items {0}/{1}",
"항목마다 따로 (기본)": "Each item separately (default)", "항목마다 따로 (기본)": "Each item separately (default)",
"해": "Year",
"해상도 올리기 메모리": "Upscaling memory", "해상도 올리기 메모리": "Upscaling memory",
"해상도 올리기 모델": "Upscaling model", "해상도 올리기 모델": "Upscaling model",
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "Download an upscaling model (Settings > AI images)", "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "Download an upscaling model (Settings > AI images)",

View File

@ -433,8 +433,8 @@
"ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ", "ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ",
"ZIP 이름": "ZIP の名前", "ZIP 이름": "ZIP の名前",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[C] 番号・[D] 日付・[T] 時刻・拡張子はそのまま 例: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[N2-5] その 2~5 文字目・[C] 番号・[D] 日付・[T] 時刻・♪ 音楽タグ・拡張子はそのまま 例: Show S01E[C]",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。",
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "[許可] を押すと Android の設定が開きます。JJ_MKVMaker を許可して戻ると、読み取れなかった動画を自動的に再分析します。", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "[許可] を押すと Android の設定が開きます。JJ_MKVMaker を許可して戻ると、読み取れなかった動画を自動的に再分析します。",
@ -656,6 +656,7 @@
"가로 고정": "横向き固定", "가로 고정": "横向き固定",
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)",
"가리기": "隠す", "가리기": "隠す",
"가수": "アーティスト",
"가입 → 프로필 → API consumers → New consumer": "登録 → プロフィール → API consumers → New consumer", "가입 → 프로필 → API consumers → New consumer": "登録 → プロフィール → API consumers → New consumer",
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "最も速い処理装置を探しています (初回のみ): {0}", "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "最も速い処理装置を探しています (初回のみ): {0}",
"가져오기": "取り込む", "가져오기": "取り込む",
@ -1815,6 +1816,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
"앞의 버튼 보기": "前のボタンを表示", "앞의 버튼 보기": "前のボタンを表示",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앨범": "アルバム",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。",
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)", "앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
@ -2065,6 +2067,7 @@
"음성인식 중 {0}%": "音声認識中 {0}%", "음성인식 중 {0}%": "音声認識中 {0}%",
"음소거 (M)": "ミュート(M)", "음소거 (M)": "ミュート(M)",
"음악": "音楽", "음악": "音楽",
"음악 태그 넣기": "音楽タグを挿入",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "この PC が接続しているネットワークが「プライベート ネットワーク」か確認します: 設定 > ネットワークとインターネット > Wi-Fi (またはイーサネット) > 接続中のネットワーク > ネットワーク プロファイルの種類 = プライベート。自宅や信頼できるネットワークでのみプライベートにしてください。", "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "この PC が接続しているネットワークが「プライベート ネットワーク」か確認します: 設定 > ネットワークとインターネット > Wi-Fi (またはイーサネット) > 接続中のネットワーク > ネットワーク プロファイルの種類 = プライベート。自宅や信頼できるネットワークでのみプライベートにしてください。",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です", "이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
@ -2690,6 +2693,7 @@
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "トラッカー・ピアに届きません ({0} 秒)", "트래커 · 피어에 닿지 않습니다 ({0}초째)": "トラッカー・ピアに届きません ({0} 秒)",
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}", "트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
"트랙 {0}": "トラック {0}", "트랙 {0}": "トラック {0}",
"트랙 번호": "トラック番号",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
"트레이 정리": "トレイの後片付け", "트레이 정리": "トレイの後片付け",
"트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "トレイの JJ_MKVMaker アイコンをクリックすると開きます。", "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "トレイの JJ_MKVMaker アイコンをクリックすると開きます。",
@ -2856,6 +2860,7 @@
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
"항목 {0}/{1}": "項目 {0}/{1}", "항목 {0}/{1}": "項目 {0}/{1}",
"항목마다 따로 (기본)": "項目ごとに (既定)", "항목마다 따로 (기본)": "項目ごとに (既定)",
"해": "年",
"해상도 올리기 메모리": "高解像度化のメモリ", "해상도 올리기 메모리": "高解像度化のメモリ",
"해상도 올리기 모델": "高解像度化モデル", "해상도 올리기 모델": "高解像度化モデル",
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "高解像度化モデルをダウンロードしてください (環境設定 > AI 画像)", "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "高解像度化モデルをダウンロードしてください (環境設定 > AI 画像)",

View File

@ -433,8 +433,8 @@
"ZIP 이 있는 폴더로": "到 ZIP 所在文件夹", "ZIP 이 있는 폴더로": "到 ZIP 所在文件夹",
"ZIP 이름": "ZIP 名称", "ZIP 이름": "ZIP 名称",
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]", "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [C] 编号 · [D] 日期 · [T] 时间 · 扩展名不变 例: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [N2-5] 其第 2~5 个字 · [C] 编号 · [D] 日期 · [T] 时间 · ♪ 音乐标签 · 扩展名不变 例: Show S01E[C]",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。",
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "点按 [允许] 将打开 Android 设置。允许 JJ_MKVMaker 后返回,无法读取的视频会自动重新分析。", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "点按 [允许] 将打开 Android 设置。允许 JJ_MKVMaker 后返回,无法读取的视频会自动重新分析。",
@ -656,6 +656,7 @@
"가로 고정": "固定横屏", "가로 고정": "固定横屏",
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)", "가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)",
"가리기": "隐藏", "가리기": "隐藏",
"가수": "艺术家",
"가입 → 프로필 → API consumers → New consumer": "注册 → 个人资料 → API consumers → New consumer", "가입 → 프로필 → API consumers → New consumer": "注册 → 个人资料 → API consumers → New consumer",
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "正在寻找最快的处理器 (仅首次): {0}", "가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "正在寻找最快的处理器 (仅首次): {0}",
"가져오기": "导入", "가져오기": "导入",
@ -1815,6 +1816,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
"앞의 버튼 보기": "前面的按钮", "앞의 버튼 보기": "前面的按钮",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앨범": "专辑",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。", "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。",
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)", "앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
@ -2065,6 +2067,7 @@
"음성인식 중 {0}%": "正在识别语音 {0}%", "음성인식 중 {0}%": "正在识别语音 {0}%",
"음소거 (M)": "静音(M)", "음소거 (M)": "静音(M)",
"음악": "音乐", "음악": "音乐",
"음악 태그 넣기": "插入音乐标签",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "确认这台电脑连接的网络是“专用网络”:设置 > 网络和 Internet > WLAN (或以太网) > 已连接的网络 > 网络配置文件类型 = 专用。仅在家庭或可信网络中设为专用。", "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "确认这台电脑连接的网络是“专用网络”:设置 > 网络和 Internet > WLAN (或以太网) > 已连接的网络 > 网络配置文件类型 = 专用。仅在家庭或可信网络中设为专用。",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录", "이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
@ -2690,6 +2693,7 @@
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "无法连接 tracker 或对等方 ({0} 秒)", "트래커 · 피어에 닿지 않습니다 ({0}초째)": "无法连接 tracker 或对等方 ({0} 秒)",
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}", "트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
"트랙 {0}": "轨道 {0}", "트랙 {0}": "轨道 {0}",
"트랙 번호": "音轨号",
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开", "트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
"트레이 정리": "清理托盘", "트레이 정리": "清理托盘",
"트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "点击托盘中的 JJ_MKVMaker 图标即可打开。", "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "点击托盘中的 JJ_MKVMaker 图标即可打开。",
@ -2856,6 +2860,7 @@
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。", "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
"항목 {0}/{1}": "项目 {0}/{1}", "항목 {0}/{1}": "项目 {0}/{1}",
"항목마다 따로 (기본)": "逐个项目 (默认)", "항목마다 따로 (기본)": "逐个项目 (默认)",
"해": "年份",
"해상도 올리기 메모리": "提升分辨率内存", "해상도 올리기 메모리": "提升分辨率内存",
"해상도 올리기 모델": "提高分辨率模型", "해상도 올리기 모델": "提高分辨率模型",
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "请下载提高分辨率模型 (设置 > AI 图片)", "해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "请下载提高分辨率模型 (设置 > AI 图片)",

236
lib/core/audio_tags.dart Normal file
View File

@ -0,0 +1,236 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 음악 파일의 태그 (제목 · 가수 · 앨범 · 트랙 · 해) - 일괄 이름 바꾸기의 [Title] · [Artist] 등.
/// 새 패키지 없이 파일 앞부분만 읽는다: MP3 (ID3v2.2 · 2.3 · 2.4, 없으면 끝의 ID3v1) · FLAC (Vorbis comment) · M4A (MP4 ilst).
/// 읽지 못하는 것은 비운다
class AudioTags {
final String? title, artist, album, track, year;
const AudioTags({this.title, this.artist, this.album, this.track, this.year});
bool get isEmpty => [title, artist, album, track, year].every((x) => x == null || x.isEmpty);
/// 트랙 번호만 ("3/12" → "3")
String? get trackNumber {
final m = RegExp(r'\d+').firstMatch(track ?? '');
return m?.group(0);
}
}
/// 이 기기의 파일에서 읽는다 (앞 1MB · 끝 128바이트). 원격 · 못 읽으면 빈 태그
Future<AudioTags> readAudioTags(String path) async {
RandomAccessFile? f;
try {
f = await File(path).open();
final len = await f.length();
final head = await f.read(1024 * 1024);
Uint8List? tail;
if (len >= 128) {
await f.setPosition(len - 128);
tail = await f.read(128);
}
return parseAudioTags(head, tail: tail);
} catch (_) {
return const AudioTags();
} finally {
await f?.close();
}
}
/// [head] (파일 앞부분) · [tail] (끝 128바이트, ID3v1) 에서 태그를 읽는다
AudioTags parseAudioTags(Uint8List head, {Uint8List? tail}) {
try {
if (_starts(head, 'ID3')) {
final t = _id3v2(head);
if (!t.isEmpty) return t;
}
if (_starts(head, 'fLaC')) return _flac(head);
if (head.length > 12 && ascii.decode(head.sublist(4, 8), allowInvalid: true) == 'ftyp') return _mp4(head);
if (tail != null && tail.length == 128 && _starts(tail, 'TAG')) return _id3v1(tail);
} catch (_) {}
return const AudioTags();
}
bool _starts(Uint8List b, String s) => b.length >= s.length && ascii.decode(b.sublist(0, s.length), allowInvalid: true) == s;
int _be(Uint8List b, int o, int n) {
var v = 0;
for (var i = 0; i < n; i++) {
v = (v << 8) | b[o + i];
}
return v;
}
int _syncsafe(Uint8List b, int o) => (b[o] << 21) | (b[o + 1] << 14) | (b[o + 2] << 7) | b[o + 3];
String _clean(String s) => s.replaceAll('\u0000', ' ').trim();
/// ID3 글: 첫 바이트가 인코딩 (0 latin1 · 1 UTF-16 (BOM) · 2 UTF-16BE · 3 UTF-8)
String _id3Text(Uint8List d) {
if (d.isEmpty) return '';
final enc = d[0];
final body = d.sublist(1);
switch (enc) {
case 1:
case 2:
var le = false;
var s = 0;
if (enc == 1 && body.length >= 2) {
if (body[0] == 0xFF && body[1] == 0xFE) {
le = true;
s = 2;
} else if (body[0] == 0xFE && body[1] == 0xFF) {
s = 2;
}
}
final units = <int>[];
for (var i = s; i + 1 < body.length; i += 2) {
final u = le ? body[i] | (body[i + 1] << 8) : (body[i] << 8) | body[i + 1];
if (u == 0) break;
units.add(u);
}
return _clean(String.fromCharCodes(units));
case 3:
return _clean(utf8.decode(body, allowMalformed: true));
default:
return _clean(latin1.decode(body));
}
}
AudioTags _id3v2(Uint8List b) {
final ver = b[3];
final size = _syncsafe(b, 6);
final end = (10 + size).clamp(0, b.length);
final m = <String, String>{};
var o = 10;
// 확장 머리 (v2.3 · 2.4) 는 건너뛴다
if ((b[5] & 0x40) != 0 && ver >= 3) o += ver == 4 ? _syncsafe(b, 10) : 4 + _be(b, 10, 4);
final idLen = ver == 2 ? 3 : 4;
final hdr = ver == 2 ? 6 : 10;
while (o + hdr <= end) {
final id = ascii.decode(b.sublist(o, o + idLen), allowInvalid: true);
if (id.codeUnits.every((c) => c == 0)) break;
final len = ver == 2 ? _be(b, o + 3, 3) : (ver == 4 ? _syncsafe(b, o + 4) : _be(b, o + 4, 4));
final start = o + hdr;
if (len <= 0 || start + len > end) break;
if (id.startsWith('T')) m[id] = _id3Text(b.sublist(start, start + len));
o = start + len;
}
String? g(List<String> ids) {
for (final id in ids) {
final v = m[id];
if (v != null && v.isNotEmpty) return v;
}
return null;
}
return AudioTags(
title: g(['TIT2', 'TT2']),
artist: g(['TPE1', 'TP1', 'TPE2', 'TP2']),
album: g(['TALB', 'TAL']),
track: g(['TRCK', 'TRK']),
year: g(['TDRC', 'TYER', 'TYE'])?.split(RegExp(r'[-T ]')).first,
);
}
AudioTags _id3v1(Uint8List t) {
String s(int o, int n) => _clean(latin1.decode(t.sublist(o, o + n)));
final track = t[125] == 0 && t[126] != 0 ? '${t[126]}' : null;
return AudioTags(title: s(3, 30), artist: s(33, 30), album: s(63, 30), year: s(93, 4), track: track);
}
AudioTags _flac(Uint8List b) {
var o = 4;
while (o + 4 <= b.length) {
final last = (b[o] & 0x80) != 0;
final type = b[o] & 0x7F;
final len = _be(b, o + 1, 3);
final start = o + 4;
if (start + len > b.length) break;
if (type == 4) return _vorbis(b.sublist(start, start + len));
if (last) break;
o = start + len;
}
return const AudioTags();
}
int _le32(Uint8List b, int o) => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24);
AudioTags _vorbis(Uint8List d) {
var o = 4 + _le32(d, 0); // 만든 프로그램 이름
final n = _le32(d, o);
o += 4;
final m = <String, String>{};
for (var i = 0; i < n && o + 4 <= d.length; i++) {
final len = _le32(d, o);
o += 4;
if (o + len > d.length) break;
final s = utf8.decode(d.sublist(o, o + len), allowMalformed: true);
o += len;
final eq = s.indexOf('=');
if (eq > 0) m.putIfAbsent(s.substring(0, eq).toUpperCase(), () => _clean(s.substring(eq + 1)));
}
return AudioTags(
title: m['TITLE'],
artist: m['ARTIST'] ?? m['ALBUMARTIST'],
album: m['ALBUM'],
track: m['TRACKNUMBER'],
year: m['DATE']?.split('-').first,
);
}
/// MP4: moov → udta → meta (머리 4바이트) → ilst 의 ©nam · ©ART · ©alb · trkn · ©day (data 상자)
AudioTags _mp4(Uint8List b) {
final m = <String, String>{};
void walk(int from, int to, List<String> path) {
var o = from;
while (o + 8 <= to) {
var size = _be(b, o, 4);
final type = latin1.decode(b.sublist(o + 4, o + 8));
var hdr = 8;
if (size == 1 && o + 16 <= to) {
size = _be(b, o + 8, 8);
hdr = 16;
} else if (size == 0) {
size = to - o;
}
if (size < hdr || o + size > to) break;
final inner = o + hdr;
final end = o + size;
if (type == 'moov' || type == 'udta' || type == 'ilst') {
walk(inner, end, [...path, type]);
} else if (type == 'meta') {
walk(inner + 4, end, [...path, type]);
} else if (path.isNotEmpty && path.last == 'ilst') {
// 항목 안의 data: 8 머리 + 4 형식 + 4 나라 + 값
var p2 = inner;
while (p2 + 16 <= end) {
final ds = _be(b, p2, 4);
final dt = latin1.decode(b.sublist(p2 + 4, p2 + 8));
if (ds < 16 || p2 + ds > end) break;
if (dt == 'data') {
final val = b.sublist(p2 + 16, p2 + ds);
if (type == 'trkn' && val.length >= 4) {
m[type] = '${_be(val, 2, 2)}';
} else {
m[type] = _clean(utf8.decode(val, allowMalformed: true));
}
break;
}
p2 += ds;
}
}
o = end;
}
}
walk(0, b.length, const []);
return AudioTags(
title: m['\u00a9nam'],
artist: m['\u00a9ART'] ?? m['aART'],
album: m['\u00a9alb'],
track: m['trkn'],
year: m['\u00a9day']?.split('-').first,
);
}

View File

@ -1,6 +1,7 @@
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'audio_tags.dart';
import 'file_ops.dart'; import 'file_ops.dart';
import 'vfs.dart'; import 'vfs.dart';
@ -65,14 +66,19 @@ class RenameRule {
); );
} }
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 틀에 음악 태그 ([Title] · [Artist] · [Album] · [Track] · [Year]) 가 있는지 (태그를 읽어야 하는지)
bool get usesTags => _tagToken.hasMatch(template);
static final _tagToken = RegExp(r'\[(Title|Artist|Album|Track|Year)\]', caseSensitive: false);
/// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지) /// 틀에 날짜 · 시각이 들어 있는지 (날짜를 읽어야 하는지)
bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template); bool get usesDate => RegExp(r'\[[DT]\]', caseSensitive: false).hasMatch(template);
/// 바꾸는 것이 하나도 없는 규칙 /// 바꾸는 것이 하나도 없는 규칙
bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep'; bool get isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep';
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸) /// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸).
String apply(String name, {required bool isDir, required int index, DateTime? date}) { /// [tags] 는 음악 태그 (없으면 빈칸). X-plore 비교 5-1 ③: [N2-5] = 원래 이름의 2~5 번째 글자, [N3-] = 3 번째부터 끝까지
String apply(String name, {required bool isDir, required int index, DateTime? date, AudioTags? tags}) {
final ext = isDir ? '' : p.extension(name); final ext = isDir ? '' : p.extension(name);
final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length); final base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length);
final n = start + index * step; final n = start + index * step;
@ -81,7 +87,23 @@ class RenameRule {
final day = date == null ? '' : '${date.year.toString().padLeft(4, '0')}-${two(date.month)}-${two(date.day)}'; final day = date == null ? '' : '${date.year.toString().padLeft(4, '0')}-${two(date.month)}-${two(date.day)}';
// 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로 // 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로
final time = date == null ? '' : '${two(date.hour)}-${two(date.minute)}-${two(date.second)}'; final time = date == null ? '' : '${two(date.hour)}-${two(date.minute)}-${two(date.second)}';
// 글자 범위 (1 부터, 끝 포함) - 글자는 유니코드 한 글자씩 (한글 · 이모지가 반쪽으로 잘리지 않게)
final chars = base.runes.map(String.fromCharCode).toList();
String range(Match m) {
final from = (int.parse(m[1]!) - 1).clamp(0, chars.length);
final to = m[2]!.isEmpty ? chars.length : int.parse(m[2]!).clamp(from, chars.length);
return chars.sublist(from, to).join();
}
String tag(String? v) => (v ?? '').replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
final track = tags?.trackNumber;
var out = template var out = template
.replaceAllMapped(RegExp(r'\[N(\d+)-(\d*)\]', caseSensitive: false), range)
.replaceAll(RegExp(r'\[Title\]', caseSensitive: false), tag(tags?.title))
.replaceAll(RegExp(r'\[Artist\]', caseSensitive: false), tag(tags?.artist))
.replaceAll(RegExp(r'\[Album\]', caseSensitive: false), tag(tags?.album))
.replaceAll(RegExp(r'\[Track\]', caseSensitive: false), track == null ? '' : track.padLeft(2, '0'))
.replaceAll(RegExp(r'\[Year\]', caseSensitive: false), tag(tags?.year))
.replaceAll(RegExp(r'\[N\]', caseSensitive: false), base) .replaceAll(RegExp(r'\[N\]', caseSensitive: false), base)
.replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter) .replaceAll(RegExp(r'\[C\]', caseSensitive: false), counter)
.replaceAll(RegExp(r'\[D\]', caseSensitive: false), day) .replaceAll(RegExp(r'\[D\]', caseSensitive: false), day)
@ -115,12 +137,15 @@ class RenameRow {
} }
/// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고) /// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고)
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸) /// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸), [tags] 는 음악 태그 (5-1 ③)
List<RenameRow> planRename(List<String> paths, RenameRule rule, List<RenameRow> planRename(List<String> paths, RenameRule rule,
{required bool Function(String path) isDir, required Map<String, Set<String>> siblings, Map<String, DateTime> dates = const {}}) { {required bool Function(String path) isDir,
required Map<String, Set<String>> siblings,
Map<String, DateTime> dates = const {},
Map<String, AudioTags> tags = const {}}) {
bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다 bool same(String a, String b) => a.toLowerCase() == b.toLowerCase(); // Windows · 대부분의 NAS 는 대소문자를 가리지 않는다
final names = [ final names = [
for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i, date: dates[paths[i]]) for (var i = 0; i < paths.length; i++) rule.apply(vBasename(paths[i]), isDir: isDir(paths[i]), index: i, date: dates[paths[i]], tags: tags[paths[i]])
]; ];
final rows = <RenameRow>[]; final rows = <RenameRow>[];
for (var i = 0; i < paths.length; i++) { for (var i = 0; i < paths.length; i++) {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../core/audio_tags.dart';
import '../core/batch_rename.dart'; import '../core/batch_rename.dart';
import '../core/photo_date.dart'; import '../core/photo_date.dart';
import '../core/vfs.dart'; import '../core/vfs.dart';
@ -46,6 +47,19 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
final _taken = <String, DateTime?>{}; final _taken = <String, DateTime?>{};
bool _takenLoading = false; bool _takenLoading = false;
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 음악 태그 (틀에 [Title] · [Artist] … 가 있을 때 한 번만 읽음, 이 기기의 파일만)
final _tags = <String, AudioTags>{};
bool _tagsLoading = false;
Future<void> _loadTags() async {
if (_tagsLoading || _tags.isNotEmpty) return;
_tagsLoading = true;
for (final x in widget.paths) {
_tags[x] = isDav(x) ? const AudioTags() : await readAudioTags(x);
}
if (mounted) setState(() => _tagsLoading = false);
}
/// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음) /// 폴더마다 지금 있는 이름 (읽는 중이면 비어 있음)
final _siblings = <String, Set<String>>{}; final _siblings = <String, Set<String>>{};
bool _loading = true; bool _loading = true;
@ -159,10 +173,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rule = _rule; final rule = _rule;
if (rule.usesDate && _dateSource == 'photo') _loadTaken(); if (rule.usesDate && _dateSource == 'photo') _loadTaken();
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates); if (rule.usesTags) _loadTags();
final rows = planRename(widget.paths, rule, isDir: widget.isDir, siblings: _siblings, dates: _dates, tags: _tags);
final changed = rows.where((r) => r.changed).length; final changed = rows.where((r) => r.changed).length;
final bad = rows.where((r) => r.problem != null).length; final bad = rows.where((r) => r.problem != null).length;
final canRun = !_loading && !_takenLoading && !_running && bad == 0 && changed > 0; final canRun = !_loading && !_takenLoading && !_tagsLoading && !_running && bad == 0 && changed > 0;
return AlertDialog( return AlertDialog(
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])), title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게 // 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
@ -179,7 +194,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
decoration: InputDecoration( decoration: InputDecoration(
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
labelText: tr('이름 틀'), labelText: tr('이름 틀'),
helperText: tr('[N] 원래 이름 · [C] 번호 · [D] 날짜 · [T] 시각 · 확장자는 그대로 예: Show S01E[C]'), helperText: tr('[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]'),
helperMaxLines: 4, helperMaxLines: 4,
isDense: true, isDense: true,
), ),
@ -214,6 +229,27 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
onChanged: _running ? null : (v) => setState(() => _dateSource = v!), onChanged: _running ? null : (v) => setState(() => _dateSource = v!),
), ),
), ),
// X-plore 비교 5-1 ③: 음악 태그 넣기 (이 기기의 MP3 · FLAC · M4A)
PopupMenuButton<String>(
enabled: !_running,
tooltip: tr('음악 태그 넣기'),
onSelected: _insert,
itemBuilder: (_) => [
for (final (t, label) in [
('[Artist]', tr('가수')),
('[Title]', tr('제목')),
('[Album]', tr('앨범')),
('[Track]', tr('트랙 번호')),
('[Year]', tr('해')),
])
PopupMenuItem(value: t, child: Text('$t $label')),
],
// 412 · 큰 글자에서 이 줄이 넘쳐 (253 시험) 글 대신 작은 아이콘
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Icon(Icons.music_note_outlined, size: 20, color: JjColors.accent),
),
),
]), ]),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [ Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [

127
test/audio_tags_test.dart Normal file
View File

@ -0,0 +1,127 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/audio_tags.dart';
import 'package:jj_mkvmaker/core/batch_rename.dart';
import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 5-1 ③ (관리자 지시 10/21): 음악 태그 (MP3 ID3v2 · ID3v1 · FLAC · M4A) 와 일괄 이름 바꾸기의 [Artist] · [N2-5]
List<int> _be(int v, int n) => [for (var i = n - 1; i >= 0; i--) (v >> (8 * i)) & 0xFF];
List<int> _syncsafe(int v) => [(v >> 21) & 0x7F, (v >> 14) & 0x7F, (v >> 7) & 0x7F, v & 0x7F];
List<int> _le32(int v) => [v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF];
/// ID3v2.3 (UTF-16 BOM) 또는 2.4 (UTF-8)
Uint8List id3(int ver, Map<String, String> frames) {
final body = <int>[];
frames.forEach((id, text) {
final data = ver == 4
? [3, ...utf8.encode(text)]
: [1, 0xFF, 0xFE, for (final u in text.codeUnits) ...[u & 0xFF, u >> 8]];
body
..addAll(ascii.encode(id))
..addAll(ver == 4 ? _syncsafe(data.length) : _be(data.length, 4))
..addAll([0, 0])
..addAll(data);
});
return Uint8List.fromList([...ascii.encode('ID3'), ver, 0, 0, ..._syncsafe(body.length), ...body, ...List.filled(64, 0)]);
}
Uint8List flac(Map<String, String> tags) {
final vendor = utf8.encode('jj');
final comments = [for (final e in tags.entries) utf8.encode('${e.key}=${e.value}')];
final block = [
..._le32(vendor.length), ...vendor, ..._le32(comments.length), //
for (final c in comments) ...[..._le32(c.length), ...c],
];
final streaminfo = List.filled(34, 0);
return Uint8List.fromList([
...ascii.encode('fLaC'),
0, ..._be(34, 3), ...streaminfo, // STREAMINFO
0x84, ..._be(block.length, 3), ...block, // 마지막 · VORBIS_COMMENT
]);
}
List<int> _box(String type, List<int> body) => [..._be(8 + body.length, 4), ...latin1.encode(type), ...body];
Uint8List mp4(Map<String, List<int>> items) {
final ilst = <int>[];
items.forEach((k, v) => ilst.addAll(_box(k, _box('data', [0, 0, 0, 1, 0, 0, 0, 0, ...v]))));
final meta = _box('meta', [0, 0, 0, 0, ..._box('hdlr', List.filled(25, 0)), ..._box('ilst', ilst)]);
return Uint8List.fromList([..._box('ftyp', ascii.encode('M4A mp42isom')), ..._box('moov', _box('udta', meta))]);
}
void main() {
test('MP3 ID3v2.3 (UTF-16) · 2.4 (UTF-8): 제목 · 가수 · 앨범 · 트랙 · 해 - 한글 · 일본어', () {
final t3 = parseAudioTags(id3(3, {'TIT2': '봄날', 'TPE1': '방탄소년단', 'TALB': 'You Never Walk Alone', 'TRCK': '3/12', 'TYER': '2017'}));
expect((t3.title, t3.artist, t3.album, t3.track, t3.year), ('봄날', '방탄소년단', 'You Never Walk Alone', '3/12', '2017'));
expect(t3.trackNumber, '3');
final t4 = parseAudioTags(id3(4, {'TIT2': '夜に駆ける', 'TPE1': 'YOASOBI', 'TDRC': '2019-12-15'}));
expect((t4.title, t4.artist, t4.year), ('夜に駆ける', 'YOASOBI', '2019'));
});
test('ID3v2 가 없으면 끝의 ID3v1 · FLAC · M4A · 모르는 형식은 빈 태그 (예외 없음)', () {
final tail = Uint8List(128)..setAll(0, ascii.encode('TAG'));
tail.setAll(3, latin1.encode('Song'));
tail.setAll(33, latin1.encode('Band'));
tail.setAll(93, latin1.encode('1999'));
tail[126] = 7;
final v1 = parseAudioTags(Uint8List(200), tail: tail);
expect((v1.title, v1.artist, v1.year, v1.track), ('Song', 'Band', '1999', '7'));
final f = parseAudioTags(flac({'TITLE': '노래', 'ARTIST': '가수', 'ALBUM': '앨범', 'TRACKNUMBER': '5', 'DATE': '2020-01-01'}));
expect((f.title, f.artist, f.album, f.track, f.year), ('노래', '가수', '앨범', '5', '2020'));
final m = parseAudioTags(mp4({
'\u00a9nam': utf8.encode('Title M4A'),
'\u00a9ART': utf8.encode('Artist M4A'),
'\u00a9alb': utf8.encode('Album M4A'),
'trkn': [0, 0, 0, 9, 0, 12, 0, 0],
'\u00a9day': utf8.encode('2021'),
}));
expect((m.title, m.artist, m.album, m.track, m.year), ('Title M4A', 'Artist M4A', 'Album M4A', '9', '2021'));
expect(parseAudioTags(Uint8List.fromList(utf8.encode('hello world, not audio'))).isEmpty, isTrue);
expect(parseAudioTags(Uint8List.fromList([...ascii.encode('ID3'), 3, 0, 0, 0x7F, 0x7F, 0x7F, 0x7F])).isEmpty, isTrue, reason: '잘린 머리');
});
test('이름 틀: [Track] - [Artist] - [Title] · 이름에 못 쓰는 글자는 _ · 태그가 없으면 빈칸 · [N2-5] · [N3-]', () {
const tags = AudioTags(title: 'A/B: C?', artist: '가수', album: '앨범', track: '3/12', year: '2017');
const r = RenameRule(template: '[Track] - [Artist] - [Title]');
expect(r.usesTags, isTrue);
expect(r.apply('x.mp3', isDir: false, index: 0, tags: tags), '03 - 가수 - A_B_ C_.mp3');
expect(r.apply('x.mp3', isDir: false, index: 0), '- -.mp3', reason: '태그가 없으면 빈칸');
expect(const RenameRule(template: '[N2-5]').apply('가나다라마바.txt', isDir: false, index: 0), '나다라마.txt');
expect(const RenameRule(template: '[N3-]_[C]').apply('ab_cdef.txt', isDir: false, index: 1), '_cdef_02.txt');
expect(const RenameRule(template: '[N9-12]x').apply('abc.txt', isDir: false, index: 0), 'x.txt', reason: '범위 밖은 빈칸');
expect(const RenameRule(template: '[N]').usesTags, isFalse);
});
testWidgets('창: [Artist] - [Title] 이면 이 기기의 MP3 태그를 읽어 미리 보기 · [♪ 태그] 메뉴로 넣기', (tester) async {
tester.view.physicalSize = const Size(1000, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tmp = Directory.systemTemp.createTempSync('jj_tags_');
addTearDown(() => tmp.deleteSync(recursive: true));
final song = File(p.join(tmp.path, 'track01.mp3'))..writeAsBytesSync(id3(3, {'TIT2': '봄날', 'TPE1': 'BTS'}));
final nav = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold()));
showBatchRename(nav.currentContext!, [song.path], isDir: (_) => false, initial: const RenameRule(template: '[Artist] - [Title]'));
for (var i = 0; i < 60 && find.text('BTS - 봄날.mp3').evaluate().isEmpty; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
expect(find.text('BTS - 봄날.mp3'), findsOneWidget);
// 메뉴로 [Year] 넣기
await tester.tap(find.byTooltip('음악 태그 넣기'));
await tester.pumpAndSettle();
expect(find.text('[Album] 앨범'), findsOneWidget);
await tester.tap(find.text('[Year] 해'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, '[Artist] - [Title][Year]'), findsOneWidget);
});
}