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
parent
f92f801e7a
commit
3444d84f35
|
|
@ -433,8 +433,8 @@
|
|||
"ZIP 이 있는 폴더로": "To the ZIP's folder",
|
||||
"ZIP 이름": "ZIP name",
|
||||
"[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] 원래 이름 · [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.",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "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.",
|
||||
|
|
@ -656,6 +656,7 @@
|
|||
"가로 고정": "Landscape only",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "Lock landscape · lock portrait, or auto (follows how you hold the device and its auto-rotate setting)",
|
||||
"가리기": "Hide",
|
||||
"가수": "Artist",
|
||||
"가입 → 프로필 → API consumers → New consumer": "Sign up → Profile → API consumers → New consumer",
|
||||
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "Finding the fastest processor (first time only): {0}",
|
||||
"가져오기": "Import",
|
||||
|
|
@ -1815,6 +1816,7 @@
|
|||
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
|
||||
"앞의 버튼 보기": "Previous buttons",
|
||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "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",
|
||||
"앱 목록이 열렸다면, 목록에서 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)",
|
||||
|
|
@ -2065,6 +2067,7 @@
|
|||
"음성인식 중 {0}%": "Recognizing speech {0}%",
|
||||
"음소거 (M)": "Mute (M)",
|
||||
"음악": "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 의 안전 저장소를 풀 수 없었습니다. 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",
|
||||
|
|
@ -2690,6 +2693,7 @@
|
|||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "Can't reach trackers or peers ({0} s)",
|
||||
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
|
||||
"트랙 {0}": "Track {0}",
|
||||
"트랙 번호": "Track number",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "Reopen with the tray icon or the hotkey below",
|
||||
"트레이 정리": "Clean up tray",
|
||||
"트레이의 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}/{1}": "Items {0}/{1}",
|
||||
"항목마다 따로 (기본)": "Each item separately (default)",
|
||||
"해": "Year",
|
||||
"해상도 올리기 메모리": "Upscaling memory",
|
||||
"해상도 올리기 모델": "Upscaling model",
|
||||
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "Download an upscaling model (Settings > AI images)",
|
||||
|
|
|
|||
|
|
@ -433,8 +433,8 @@
|
|||
"ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ",
|
||||
"ZIP 이름": "ZIP の名前",
|
||||
"[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] 원래 이름 · [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 を許可して戻ると、読み取れなかった動画を自動的に再分析します。",
|
||||
|
|
@ -656,6 +656,7 @@
|
|||
"가로 고정": "横向き固定",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "横向き固定・縦向き固定、または自動(端末の向きに合わせ、端末の自動回転設定に従う)",
|
||||
"가리기": "隠す",
|
||||
"가수": "アーティスト",
|
||||
"가입 → 프로필 → API consumers → New consumer": "登録 → プロフィール → API consumers → New consumer",
|
||||
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "最も速い処理装置を探しています (初回のみ): {0}",
|
||||
"가져오기": "取り込む",
|
||||
|
|
@ -1815,6 +1816,7 @@
|
|||
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
|
||||
"앞의 버튼 보기": "前のボタンを表示",
|
||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
|
||||
"앨범": "アルバム",
|
||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
|
||||
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。",
|
||||
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
|
||||
|
|
@ -2065,6 +2067,7 @@
|
|||
"음성인식 중 {0}%": "音声認識中 {0}%",
|
||||
"음소거 (M)": "ミュート(M)",
|
||||
"음악": "音楽",
|
||||
"음악 태그 넣기": "音楽タグを挿入",
|
||||
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "この PC が接続しているネットワークが「プライベート ネットワーク」か確認します: 設定 > ネットワークとインターネット > Wi-Fi (またはイーサネット) > 接続中のネットワーク > ネットワーク プロファイルの種類 = プライベート。自宅や信頼できるネットワークでのみプライベートにしてください。",
|
||||
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}",
|
||||
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
|
||||
|
|
@ -2690,6 +2693,7 @@
|
|||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "トラッカー・ピアに届きません ({0} 秒)",
|
||||
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
|
||||
"트랙 {0}": "トラック {0}",
|
||||
"트랙 번호": "トラック番号",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "トレイアイコンを押すか、下のショートカットで再表示します",
|
||||
"트레이 정리": "トレイの後片付け",
|
||||
"트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "トレイの JJ_MKVMaker アイコンをクリックすると開きます。",
|
||||
|
|
@ -2856,6 +2860,7 @@
|
|||
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
|
||||
"항목 {0}/{1}": "項目 {0}/{1}",
|
||||
"항목마다 따로 (기본)": "項目ごとに (既定)",
|
||||
"해": "年",
|
||||
"해상도 올리기 메모리": "高解像度化のメモリ",
|
||||
"해상도 올리기 모델": "高解像度化モデル",
|
||||
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "高解像度化モデルをダウンロードしてください (環境設定 > AI 画像)",
|
||||
|
|
|
|||
|
|
@ -433,8 +433,8 @@
|
|||
"ZIP 이 있는 폴더로": "到 ZIP 所在文件夹",
|
||||
"ZIP 이름": "ZIP 名称",
|
||||
"[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] 원래 이름 · [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 后返回,无法读取的视频会自动重新分析。",
|
||||
|
|
@ -656,6 +656,7 @@
|
|||
"가로 고정": "固定横屏",
|
||||
"가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)": "锁定横屏 · 锁定竖屏,或自动(随设备方向旋转,遵循设备的自动旋转设置)",
|
||||
"가리기": "隐藏",
|
||||
"가수": "艺术家",
|
||||
"가입 → 프로필 → API consumers → New consumer": "注册 → 个人资料 → API consumers → New consumer",
|
||||
"가장 빠른 처리 장치를 찾는 중 (처음 한 번): {0}": "正在寻找最快的处理器 (仅首次): {0}",
|
||||
"가져오기": "导入",
|
||||
|
|
@ -1815,6 +1816,7 @@
|
|||
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
|
||||
"앞의 버튼 보기": "前面的按钮",
|
||||
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
|
||||
"앨범": "专辑",
|
||||
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
|
||||
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。",
|
||||
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
|
||||
|
|
@ -2065,6 +2067,7 @@
|
|||
"음성인식 중 {0}%": "正在识别语音 {0}%",
|
||||
"음소거 (M)": "静音(M)",
|
||||
"음악": "音乐",
|
||||
"음악 태그 넣기": "插入音乐标签",
|
||||
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "确认这台电脑连接的网络是“专用网络”:设置 > 网络和 Internet > WLAN (或以太网) > 已连接的网络 > 网络配置文件类型 = 专用。仅在家庭或可信网络中设为专用。",
|
||||
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}",
|
||||
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
|
||||
|
|
@ -2690,6 +2693,7 @@
|
|||
"트래커 · 피어에 닿지 않습니다 ({0}초째)": "无法连接 tracker 或对等方 ({0} 秒)",
|
||||
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
|
||||
"트랙 {0}": "轨道 {0}",
|
||||
"트랙 번호": "音轨号",
|
||||
"트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다": "点击托盘图标或用下方快捷键重新打开",
|
||||
"트레이 정리": "清理托盘",
|
||||
"트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "点击托盘中的 JJ_MKVMaker 图标即可打开。",
|
||||
|
|
@ -2856,6 +2860,7 @@
|
|||
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
|
||||
"항목 {0}/{1}": "项目 {0}/{1}",
|
||||
"항목마다 따로 (기본)": "逐个项目 (默认)",
|
||||
"해": "年份",
|
||||
"해상도 올리기 메모리": "提升分辨率内存",
|
||||
"해상도 올리기 모델": "提高分辨率模型",
|
||||
"해상도 올리기 모델을 받아 주세요 (환경 설정 > AI 그림)": "请下载提高分辨率模型 (设置 > AI 图片)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/tr.dart';
|
||||
import 'audio_tags.dart';
|
||||
import 'file_ops.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 isIdentity => template == '[N]' && find.isEmpty && caseMode == 'keep';
|
||||
|
||||
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸)
|
||||
String apply(String name, {required bool isDir, required int index, DateTime? date}) {
|
||||
/// [name] (확장자 포함) 의 새 이름. [index] 는 0 부터 (번호 = start + index × step). [date] 는 [D] · [T] 에 (없으면 빈칸).
|
||||
/// [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 base = isDir || ext.isEmpty ? name : name.substring(0, name.length - ext.length);
|
||||
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)}';
|
||||
// 시각은 ':' 를 쓸 수 없어 (Windows) '-' 로
|
||||
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
|
||||
.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'\[C\]', caseSensitive: false), counter)
|
||||
.replaceAll(RegExp(r'\[D\]', caseSensitive: false), day)
|
||||
|
|
@ -115,12 +137,15 @@ class RenameRow {
|
|||
}
|
||||
|
||||
/// 미리 보기: [paths] 차례대로 번호를 매긴다. [siblings] 는 폴더마다 지금 있는 이름 (고르지 않은 것과 겹치는지 보려고)
|
||||
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸)
|
||||
/// [dates] 는 [D] · [T] 에 쓰는 항목마다의 날짜 (없으면 빈칸), [tags] 는 음악 태그 (5-1 ③)
|
||||
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 는 대소문자를 가리지 않는다
|
||||
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>[];
|
||||
for (var i = 0; i < paths.length; i++) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/audio_tags.dart';
|
||||
import '../core/batch_rename.dart';
|
||||
import '../core/photo_date.dart';
|
||||
import '../core/vfs.dart';
|
||||
|
|
@ -46,6 +47,19 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
final _taken = <String, DateTime?>{};
|
||||
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>>{};
|
||||
bool _loading = true;
|
||||
|
|
@ -159,10 +173,11 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
Widget build(BuildContext context) {
|
||||
final rule = _rule;
|
||||
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 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(
|
||||
title: Text(trf('이름 일괄 바꾸기 · {0}개', [widget.paths.length])),
|
||||
// 낮은 화면 · 큰 글자에서는 창 안이 스크롤 (253 시험: 아래가 넘쳤다). 폭은 고정이라 미리 보기 목록은 그대로 게으르게
|
||||
|
|
@ -179,7 +194,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
decoration: InputDecoration(
|
||||
// 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에
|
||||
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,
|
||||
isDense: true,
|
||||
),
|
||||
|
|
@ -214,6 +229,27 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
|
|||
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),
|
||||
Wrap(spacing: 10, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.end, children: [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue