【Day56】音楽タグ編集アプリを強化する ― Web APIで自動タグ取得&フォルダ一括編集(Python・Windows)

未分類

Day54 では、Python(mutagen)とGUI(PySimpleGUI)で「MP3/FLAC のタイトル・アーティストを編集する Windows アプリ」を作りました。本稿 Day56 では、そのアプリを実用レベルにグッと引き上げる2つの機能追加を行います:

  1. Web APIを使った自動タグ取得:曲名やアーティストから、MusicBrainz の公開APIを叩いてメタデータ(アルバム名など)を引き当て、タグへ反映します(無料・要User-Agent、1秒/回の節度あるアクセス推奨)。
  2. フォルダ一括編集(バッチ):指定フォルダ配下の 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)へクエリ → 最上位ヒットを採用 → 取得できた albumtitleartist を統合 → GUIの「反映」ボタンで保存。
  • 一括編集:フォルダ選択 → 拡張子フィルタ(mp3/flac)で再帰走査 → 1件ずつ (必要に応じてAPIで補完) → Dry-runプレビュー → 実行で書き込み。進捗バー/ログ表示つき。

注意:タグ仕様はMP3(ID3)とFLAC(Vorbis Comment)で違いますが、mutagenEasyID3/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])から titleartist-credit[0].namereleases[0].title(≒アルバム)を採用。MusicBrainz API WS/2 検索の一般原則に従います。:contentReference[oaicite:2]{index=2}
  • MB_HEADERSUser-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):ファイル名や既存タグに頼らず、音の指紋で照合して正確性を上げられます。導入には Chromaprintpyacoustid が必要(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. 運用のコツ(失敗しないために)

  1. まずはDRYで:一括編集は破壊的になり得ます。まずは Dry-run でログを確認し、書き込みの影響を把握してから実行。
  2. ファイルのバックアップ:念のため対象フォルダをコピーしてからバッチ処理を試すと安心です。
  3. 検索語の正規化:ファイル名が雑な場合は、簡易正規化(不要文字除去・全角半角調整)でヒット率が上がります。
  4. 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}

コメント

タイトルとURLをコピーしました