Day54 では、Python(mutagen
)とGUI(PySimpleGUI
)で「MP3/FLAC のタイトル・アーティストを編集する Windows アプリ」を作りました。本稿 Day56 では、そのアプリを実用レベルにグッと引き上げる2つの機能追加を行います:
- Web APIを使った自動タグ取得:曲名やアーティストから、MusicBrainz の公開APIを叩いてメタデータ(アルバム名など)を引き当て、タグへ反映します(無料・要User-Agent、1秒/回の節度あるアクセス推奨)。
- フォルダ一括編集(バッチ):指定フォルダ配下の MP3/FLAC を走査し、まとめてタグを書き換えます。Dry-run(実行前プレビュー)/空欄のみ上書きなど安全策も実装。
APIの選定理由:MusicBrainz は無償で使えてJSON出力が簡単、検索も柔軟です(/ws/2
系エンドポイント/fmt=json
指定)。利用時は User-Agent を必ず付けましょう(アプリ名・バージョン・連絡先)。また、節度あるリクエスト(概ね1秒あたり1回)が推奨です。API仕様と制限は公式ドキュメントを確認してください。:contentReference[oaicite:0]{index=0}
1. 機能追加の全体像(何をどう足すか)
- 自動タグ取得:ファイル名(例「Artist – Title.mp3」)や既存タグから 候補のアーティスト/曲名 を抽出 → MusicBrainz 検索API(Recording Search)へクエリ → 最上位ヒットを採用 → 取得できた
album
、title
、artist
を統合 → GUIの「反映」ボタンで保存。 - 一括編集:フォルダ選択 → 拡張子フィルタ(mp3/flac)で再帰走査 → 1件ずつ (必要に応じてAPIで補完) → Dry-runプレビュー → 実行で書き込み。進捗バー/ログ表示つき。
注意:タグ仕様はMP3(ID3)とFLAC(Vorbis Comment)で違いますが、mutagen
の EasyID3
/FLAC
クラス経由なら title
/artist
/album
など共通キーで扱えます。なお、mutagenはデフォルトで ID3v2.4 を書く点など互換性の注意があるため(古いツールは読めない場合)、必要に応じて ID3.save(v1=2)
なども検討してください。:contentReference[oaicite:1]{index=1}
2. まずは準備(パッケージ導入)
pip install PySimpleGUI mutagen requests
<既存のDay54アプリをお持ちなら、そのまま上記を追加インストールでOKです>
3. コード:自動タグ取得+一括編集を備えた拡張版GUI
以下を music_tag_editor_plus.py
として保存・実行してください。
(コメント入り/WordPressにコピペ可/Day54の延長として動作)
import os
import time
import json
import requests # Web APIアクセス
import PySimpleGUI as sg
from mutagen.easyid3 import EasyID3
from mutagen.flac import FLAC
# ====== 設定:MusicBrainzのUser-Agent(必須)======
# 公式は「アプリ名/バージョン (連絡先)」の形式を推奨
# 例: "PyLogicTagger/0.2 (contact@example.com)"
USER_AGENT = "PyLogicTagger/0.2 (contact@example.com)" # ←各自変更してください
MB_BASE = "https://musicbrainz.org/ws/2" # APIのベースURL(/ws/2)
MB_HEADERS = {"User-Agent": USER_AGENT} # UA必須(礼儀&制限回避のため)
MB_SLEEP_SEC = 1.1 # 1秒に1回程度の節度(バッチ用)
# ---------- 共通:拡張子/タグ書き込み ----------
def _ext(path: str) -> str:
return os.path.splitext(path)[1].lower()
def write_tags(path: str, tags: dict, only_fill_empty: bool = False) -> bool:
"""mutagenで title/artist/album を書く。only_fill_empty=Trueなら空欄のみ埋める。"""
try:
ext = _ext(path)
if ext == ".mp3":
audio = EasyID3(path)
elif ext == ".flac":
audio = FLAC(path)
else:
return False
for k in ("title", "artist", "album"):
v = tags.get(k)
if v is None:
continue
if only_fill_empty and audio.get(k):
# 既に値がある場合はスキップ
continue
audio[k] = v
audio.save()
return True
except Exception as e:
print("write_tags error:", e)
return False
# ---------- 自動推測:ファイル名から artist/title を推定 ----------
def guess_from_filename(filename: str) -> dict:
"""
例: 'Artist - Title.mp3' → {'artist': 'Artist', 'title': 'Title'}
'01 - Artist - Title.flac' なども素直に右側2要素を採用
"""
name = os.path.splitext(os.path.basename(filename))[0]
parts = [p.strip() for p in name.split("-")]
out = {}
if len(parts) >= 2:
out["artist"] = parts[-2]
out["title"] = parts[-1]
# 最低限、空なら空文字を返す
out.setdefault("artist", "")
out.setdefault("title", "")
return out
# ---------- MusicBrainz 検索 ----------
def search_musicbrainz(artist: str, title: str, limit: int = 5) -> dict | None:
"""
Recording検索で artist & title をAND検索。
戻り値: {'title':, 'artist':, 'album': <任意>}
"""
if not artist and not title:
return None
# Luceneベースの検索構文。引用符で精度UP、JSONで欲しいのでfmt=json
# 参考: /ws/2/recording?query=recording:"..." AND artist:"..."&fmt=json
q_parts = []
if title:
q_parts.append(f'recording:"{title}"')
if artist:
q_parts.append(f'artist:"{artist}"')
query = " AND ".join(q_parts) if q_parts else title or artist
url = f"{MB_BASE}/recording"
params = {"query": query, "fmt": "json", "limit": str(limit)}
try:
r = requests.get(url, headers=MB_HEADERS, params=params, timeout=20)
r.raise_for_status()
data = r.json()
except Exception as e:
print("MusicBrainz request error:", e)
return None
recs = data.get("recordings") or []
if not recs:
return None
# 最上位を採用(score が高い=関連度が高い傾向)
top = recs[0]
# title(曲名)
out_title = top.get("title", "") or title
# artist-credit から表示名を取得
artist_credit = top.get("artist-credit") or []
out_artist = ""
if artist_credit:
# 最初の 'name' が一般に人間向け表記
c0 = artist_credit[0]
out_artist = c0.get("name") or c0.get("artist", {}).get("name", "")
# 代表的なリリース名(≒アルバム名)を拾う。recordingにはreleases配列が来る場合あり
out_album = ""
rels = top.get("releases") or []
if rels:
out_album = rels[0].get("title", "")
# 空の場合は元入力で補完
if not out_artist:
out_artist = artist
result = {"title": out_title, "artist": out_artist}
if out_album:
result["album"] = out_album
return result
# ---------- GUI:単体編集タブ ----------
def build_tab_single():
col = [
[sg.Text("ファイル(MP3/FLAC)"), sg.Input(key="-S_FILE-", expand_x=True, readonly=True),
sg.FileBrowse(file_types=(("音楽ファイル", "*.mp3;*.flac"),))],
[sg.Text("タイトル"), sg.Input(key="-S_TITLE-", expand_x=True)],
[sg.Text("アーティスト"), sg.Input(key="-S_ARTIST-", expand_x=True)],
[sg.Text("アルバム"), sg.Input(key="-S_ALBUM-", expand_x=True)],
[sg.Button("保存", key="-S_SAVE-"), sg.Push(),
sg.Button("自動取得(MusicBrainz)", key="-S_FETCH-")],
[sg.StatusBar("準備完了", key="-S_STATUS-")]
]
return sg.Tab("単体編集", [[sg.Column(col, expand_x=True)]], key="-TAB_SINGLE-")
# ---------- GUI:一括編集タブ ----------
def build_tab_batch():
col = [
[sg.Text("フォルダ"), sg.Input(key="-B_DIR-", expand_x=True, readonly=True),
sg.FolderBrowse()],
[sg.Checkbox("サブフォルダも含める", key="-B_RECURSIVE-", default=True),
sg.Checkbox("Dry-run(書き込みせず確認)", key="-B_DRY-", default=True)],
[sg.Checkbox("空欄のみ上書き(既存タグは維持)", key="-B_ONLY_EMPTY-", default=True),
sg.Checkbox("APIで自動補完を使う", key="-B_USE_API-", default=True)],
[sg.ProgressBar(100, orientation="h", size=(40, 20), key="-B_PBAR-")],
[sg.Multiline(size=(80, 18), key="-B_LOG-", autoscroll=True, expand_x=True, expand_y=True)],
[sg.Button("実行", key="-B_RUN-"), sg.Button("停止", key="-B_STOP-", disabled=True)]
]
return sg.Tab("一括編集", [[sg.Column(col, expand_x=True, expand_y=True)]], key="-TAB_BATCH-")
def iter_audio_files(root_dir: str, recursive=True):
"""フォルダから mp3/flac を列挙"""
if not recursive:
for name in os.listdir(root_dir):
p = os.path.join(root_dir, name)
if os.path.isfile(p) and _ext(p) in (".mp3", ".flac"):
yield p
return
for dpath, _, files in os.walk(root_dir):
for fn in files:
if _ext(fn) in (".mp3", ".flac"):
yield os.path.join(dpath, fn)
def run_batch(values, window):
"""一括処理本体(進捗バー/ログ更新つき)"""
bdir = values["-B_DIR-"]
if not bdir or not os.path.isdir(bdir):
sg.popup_error("フォルダを選択してください")
return
use_api = values["-B_USE_API-"]
only_empty = values["-B_ONLY_EMPTY-"]
dry = values["-B_DRY-"]
rec = values["-B_RECURSIVE-"]
files = list(iter_audio_files(bdir, recursive=rec))
total = len(files)
if total == 0:
sg.popup("対象ファイルが見つかりませんでした")
return
window["-B_STOP-"].update(disabled=False)
window["-B_LOG-"].update(value=f"対象 {total} 件\n")
count = 0
for idx, path in enumerate(files, start=1):
# 進捗バー更新
window["-B_PBAR-"].update_bar(idx, total)
window.refresh()
# APIで補完(任意)
tags = {}
if use_api:
hint = guess_from_filename(path)
api = search_musicbrainz(hint.get("artist", ""), hint.get("title", ""))
if api:
tags.update(api)
msg = f"[API] {os.path.basename(path)} → {json.dumps(api, ensure_ascii=False)}"
window["-B_LOG-"].print(msg)
time.sleep(MB_SLEEP_SEC) # 1秒/回の節度
else:
window["-B_LOG-"].print(f"[APIなし] {os.path.basename(path)}")
# Dry-run なら書き込みせずログに出す
if dry:
window["-B_LOG-"].print(f"[DRY] 書き込み予定: {path} {tags}")
else:
ok = write_tags(path, tags, only_fill_empty=only_empty)
window["-B_LOG-"].print(("[OK]" if ok else "[NG]") + f" 書き込み: {path} {tags}")
if ok: count += 1
# 停止ボタン監視
if window.was_closed():
break
# イベントキューを捌く(停止ボタンを効かせる)
try:
ev, _ = window.read(timeout=10)
if ev == "-B_STOP-":
break
except Exception:
pass
window["-B_STOP-"].update(disabled=True)
window["-B_LOG-"].print(f"完了(実書き込み {count} / 対象 {total})")
def main():
sg.theme("SystemDefault")
tab1 = build_tab_single()
tab2 = build_tab_batch()
layout = [[sg.TabGroup([[tab1, tab2]], expand_x=True, expand_y=True)]]
win = sg.Window("音楽タグ編集アプリ(自動取得+一括編集)", layout, resizable=True)
while True:
ev, val = win.read()
if ev in (sg.WINDOW_CLOSED,):
break
# --- 単体編集:保存 ---
if ev == "-S_SAVE-":
path = val["-S_FILE-"]
tags = {
"title": val["-S_TITLE-"].strip(),
"artist": val["-S_ARTIST-"].strip(),
"album": val["-S_ALBUM-"].strip(),
}
if not path:
sg.popup_error("ファイルを選択してください")
continue
if not any(tags.values()):
sg.popup_error("少なくとも1項目を入力してください")
continue
ok = write_tags(path, tags, only_fill_empty=False)
win["-S_STATUS-"].update("保存OK" if ok else "保存NG")
# --- 単体編集:APIで自動取得 ---
if ev == "-S_FETCH-":
path = val["-S_FILE-"]
if not path:
sg.popup_error("ファイルを選択してください")
continue
hint = guess_from_filename(path)
res = search_musicbrainz(hint.get("artist",""), hint.get("title",""))
if res:
win["-S_TITLE-"].update(res.get("title",""))
win["-S_ARTIST-"].update(res.get("artist",""))
win["-S_ALBUM-"].update(res.get("album",""))
win["-S_STATUS-"].update("API取得OK")
else:
win["-S_STATUS-"].update("候補なし")
# --- 一括編集:実行 ---
if ev == "-B_RUN-":
run_batch(val, win)
win.close()
if __name__ == "__main__":
main()
ポイント(実装の狙い)
search_musicbrainz()
:Recording Search を使い、recording:"曲名"
とartist:"アーティスト"
の AND でクエリ生成(JSON指定はfmt=json
)。最上位ヒット(recordings[0]
)からtitle
、artist-credit[0].name
、releases[0].title
(≒アルバム)を採用。MusicBrainz API WS/2 検索の一般原則に従います。:contentReference[oaicite:2]{index=2}MB_HEADERS
:User-Agent 必須。アプリ名/版/連絡先を明記してください(礼儀と運用上の必須事項)。レートは「秒間1回」程度を目安にtime.sleep(1.1)
を入れています(無闇な連打は避ける)。:contentReference[oaicite:3]{index=3}write_tags()
:only_fill_empty=True
なら 既存タグは尊重 し、空欄だけ埋める安全寄り仕様。guess_from_filename()
:最小ヒューリスティクス(Artist - Title
)で初期候補を作成。ヒット率向上のための下ごしらえ。- Dry-run:一括モードでは まずDRY で結果を眺めてから実書き込みに移れます(ログ&進捗バー付き)。
4. どこまで自動化できる?(高度化の選択肢)
- 音響指紋(AcoustID / Chromaprint):ファイル名や既存タグに頼らず、音の指紋で照合して正確性を上げられます。導入には Chromaprint と pyacoustid が必要(Cライブラリ+Pythonバインディング+デコーダ類)。学習コストは上がるものの精度向上が見込めます。:contentReference[oaicite:4]{index=4}
- Discogs API:ジャケット画像やリリース情報が充実。認証あり 60req/分、未認証 25req/分 のレート制限に留意(用途により有力)。:contentReference[oaicite:5]{index=5}
- mutagenの互換性:ID3v2.4を書きます。古いプレイヤー向けにはID3v1併記など検討を(互換性注記あり)。:contentReference[oaicite:6]{index=6}
5. 運用のコツ(失敗しないために)
- まずはDRYで:一括編集は破壊的になり得ます。まずは Dry-run でログを確認し、書き込みの影響を把握してから実行。
- ファイルのバックアップ:念のため対象フォルダをコピーしてからバッチ処理を試すと安心です。
- 検索語の正規化:ファイル名が雑な場合は、簡易正規化(不要文字除去・全角半角調整)でヒット率が上がります。
- APIマナー:User-Agent 明記・節度あるリクエスト・失敗時の待機とリトライ。外部サービスに優しく。:contentReference[oaicite:7]{index=7}
6. EXE化(配布用ビルド)
Windows 配布は PyInstaller
が手軽です(クロスビルド不可のため Windows 上で作成)。
pip install pyinstaller
pyinstaller --noconsole --onefile music_tag_editor_plus.py
生成物は dist/music_tag_editor_plus.exe
。必要に応じて --icon=app.ico
を追加。
7. まとめ
Day56では、Day54の電卓級シンプルなタグ編集アプリを、実運用に耐える自動取得&一括編集へ拡張しました。MusicBrainz APIによるタグ補完は無償で導入障壁が低く、まずはここから が現実的です。将来的に精度をさらに求めるなら、音響指紋(AcoustID/Chromaprint)やDiscogs連携、ジャケット画像の埋め込み、タグの正規化(アーティスト表記統一)などへ段階的に踏み出しましょう。
学びの要点は「外部APIの使い方(ヘッダ・レート)」「バッチ処理の安全運用(Dry-run/Only Empty)」「mutagenでのキー共通化」。これらは他のドメイン自動化でもそのまま応用できます。
参考(仕様・ドキュメント)
- MusicBrainz API(WS/2 概要・JSON):エンドポイントと検索の基本。 :contentReference[oaicite:8]{index=8}
- MusicBrainz API:アプリ識別・節度ある利用(User-Agent/1req/sec 目安)。 :contentReference[oaicite:9]{index=9}
- mutagen(ID3 / EasyID3):ID3v2.4・キーの扱いなど互換性メモ。 :contentReference[oaicite:10]{index=10}
- AcoustID/Chromaprint:音響指紋の仕組みと導入要件。 :contentReference[oaicite:11]{index=11}
- Discogs API:レート制限(認証あり60/min、未認証25/min)。 :contentReference[oaicite:12]{index=12}
コメント