空音開発Kuon R&D
仕上げ · 2025.08.21

【保存版】Python自動化:フォルダ内の演奏時間集計→YouTube概要欄チャプターの生成

以前も似たサンプルコード紹介していますが、結構使うコードですので、最新情報をアップデートしようと思います。 - Macユーザーの音楽家/エンジニア - JupyterLabやconda環境でPythonを使っている人 -

目次

以前も似たサンプルコード紹介していますが、結構使うコードですので、最新情報をアップデートしようと思います。

  • Macユーザーの音楽家/エンジニア
  • JupyterLabやconda環境でPythonを使っている人
  • アルバムまとめ動画のYouTube概要欄にチャプターを一括生成したい人

このサンプルコードを使えば、Youtubeチャプターの編集時間をカットすることができます。

サンプルコード

解説はコメントアウトにて。

Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
YouTube概要欄のチャプター行を自動生成(フォルダパスはコード内で固定)。

目的:
    - フォルダ内の音源ファイルを「先頭番号順(01,02,...)」に並べ、
      各曲の演奏時間からチャプター行を生成します。
    - mutagen が使えれば優先。無い場合は macOS の `mdls` を使って秒数を取得します。

使い方:
    1) 下の TARGET_DIR をご自分のフォルダに書き換える(今回は既に指定済み)。
    2) 端末で実行:  python3 make_youtube_chapters_fixed.py
    3) 出力されたテキストを YouTube の概要欄にコピペ。

Example:
    出力例の1行: 0:00  01 オッペケペー (1:41)
"""

from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple

# === ★ ここを書き換えるだけでOK(分析したいフォルダのパスを書いてください) ==================
TARGET_DIR = Path("ここにパスを貼り付け")
# ====================================================================================

# 対象とする拡張子(必要に応じて追加)
AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".aif", ".aiff", ".flac", ".ogg", ".oga", ".wma", ".mp4", ".mka"}

# ---------- 基本ユーティリティ ----------

def hhmmss_from_seconds(sec: float) -> str:
    """秒を h:mm:ss / m:ss に整形(例: 101→'1:41', 3661→'1:01:01')。"""
    total = int(round(sec))
    h, r = divmod(total, 3600)
    m, s = divmod(r, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"

def extract_leading_number(name: str) -> int:
    """ファイル名の先頭連番('01 ...' → 1)を抽出。無ければ 9999(末尾へ)。"""
    m = re.match(r"^(\d+)", name)
    return int(m.group(1)) if m else 9999

# ---------- 演奏時間の取得(mutagen 優先 / mdls フォールバック) ----------

def duration_seconds_mutagen(path: Path) -> Optional[float]:
    """mutagen で長さ(秒)を取得。mutagen無い/未対応なら None。"""
    try:
        from mutagen import File as MutagenFile  # 遅延インポートで未導入でもOK
    except Exception:
        return None
    try:
        m = MutagenFile(str(path))
        if m and getattr(m, "info", None) and getattr(m.info, "length", None):
            return float(m.info.length)
    except Exception:
        pass
    return None

def duration_seconds_mdls(path: Path) -> Optional[float]:
    """macOSの `mdls` から kMDItemDurationSeconds を取得。取れなければ None。"""
    try:
        out = subprocess.run(
            ["mdls", "-name", "kMDItemDurationSeconds", str(path)],
            capture_output=True, text=True, check=False
        ).stdout
        if "=" in out:
            val = out.split("=", 1)[1].strip()
            if val and val.lower() != "(null)":
                return float(val)
    except Exception:
        pass
    return None

# ---------- フォルダ走査とチャプター作成 ----------

def scan_folder(folder: Path) -> List[Tuple[str, float]]:
    """
    フォルダ内の音源を「先頭番号順」に並べ、(タイトル, 秒) の配列を返す。
    タイトルは拡張子を除いたファイル名(例: '01 オッペケペー')。
    """
    files = [
        p for p in folder.iterdir()
        if p.is_file() and not p.name.startswith(".") and p.suffix.lower() in AUDIO_EXTS
    ]
    files.sort(key=lambda p: extract_leading_number(p.name))  # 01, 02, 03 ... の順

    items: List[Tuple[str, float]] = []
    for p in files:
        sec = duration_seconds_mutagen(p)
        if sec is None:
            sec = duration_seconds_mdls(p)
        if sec is None:
            # 取得できないファイルはスキップ(必要なら 0 秒扱いに変更可)
            continue
        items.append((p.stem, float(sec)))
    return items

def make_chapters(items: List[Tuple[str, float]]) -> List[str]:
    """
    (タイトル, 秒) → チャプター行の配列を返す。
    例: ["0:00  01 タイトル (1:41)", "1:41  02 次曲 (2:51)", ...]
    """
    chapters: List[str] = []
    t = 0.0
    for title, sec in items:
        chapters.append(f"{hhmmss_from_seconds(t)}  {title} ({hhmmss_from_seconds(sec)})")
        t += sec
    return chapters

def main() -> None:
    # 1) フォルダ存在チェック
    if not TARGET_DIR.is_dir():
        print(f"[Error] Folder not found: {TARGET_DIR}")
        return

    # 2) 走査 → (タイトル, 秒) を得る
    items = scan_folder(TARGET_DIR)
    if not items:
        print("[Info] 対象が見つかりませんでした。")
        return

    # 3) チャプター生成
    chapters = make_chapters(items)

    # 4) 画面に出力(YouTube概要欄へコピペ)
    print("\n=== YouTube Chapters ===\n")
    print("\n".join(chapters))

if __name__ == "__main__":
    main()

mutagenを使う場合

mutagenを使えば以下のようなことが実現できます。

  • 演奏時間(length) の取得
  • サンプルレート(sample_rate)/チャンネル数(channels)/ビットレート(bitrate) の取得
  • タグ読取:ID3(MP3)、MP4(M4A/MP4)、FLAC(VorbisComment)、OGG、ASF(WMA)など
  • アートワークの抽出(ID3 APIC、MP4 “covr”、FLAC PICTURE など)

インストール方法ですが、(Mac/Anaconda/Jupyter 対応)conda推奨です。

Code
# 新規環境(任意)
conda create -n audiochap python=3.11 -y
conda activate audiochap

# mutagen を conda-forge から
conda install -c conda-forge mutagen -y

pip / venvの場合

Code
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install mutagen

JupyterLab の“今のカーネル”に入れる(セルで実行)場合

Code
import sys, subprocess
print(sys.executable)  # 参照中のPython確認
subprocess.check_call([sys.executable, "-m", "pip", "install", "mutagen"])

サンプルコード

Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
YouTube概要欄のチャプター行を生成(再帰走査・mutagen→mdls フォールバック付き)

- サブフォルダも含めて音源を探索(rglob)
- mutagen で長さが取れない場合は macOS の mdls を使用
- 先頭番号(01, 02, ...)で数値ソート
- 最後に成功/失敗の集計を表示して原因を把握しやすくする
"""

from __future__ import annotations
from pathlib import Path
from typing import Optional, List, Tuple
import subprocess
import re

# === ★ フォルダパス(こうたろうさんのフォルダ) ===================================
TARGET_DIR = Path("ここにパス")
# ===================================================================================

# 対象拡張子(小文字想定)。空集合なら「全ファイル試す」でもOK
AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".aif", ".aiff", ".flac", ".ogg", ".oga", ".wma", ".mp4", ".mka"}

# ---- まず mutagen を試す(未導入でも動くように遅延import) -----------------------
def duration_seconds_mutagen(path: Path) -> Optional[float]:
    try:
        from mutagen import File as MutagenFile
    except Exception:
        return None
    try:
        m = MutagenFile(str(path))
        if m and getattr(m, "info", None) and getattr(m.info, "length", None):
            return float(m.info.length)
    except Exception:
        pass
    return None

# ---- だめなら mdls(macOS標準) ---------------------------------------------------
def duration_seconds_mdls(path: Path) -> Optional[float]:
    try:
        out = subprocess.run(
            ["mdls", "-name", "kMDItemDurationSeconds", str(path)],
            capture_output=True, text=True, check=False
        ).stdout
        if "=" in out:
            val = out.split("=", 1)[1].strip()
            if val and val.lower() != "(null)":
                return float(val)
    except Exception:
        pass
    return None

# ---- ユーティリティ ---------------------------------------------------------------
def hhmmss_from_seconds(sec: float) -> str:
    total = int(round(sec))
    h, r = divmod(total, 3600)
    m, s = divmod(r, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"

def extract_leading_number(name: str) -> int:
    m = re.match(r"^(\d+)", name)
    return int(m.group(1)) if m else 9999

# ---- 走査&取得 -------------------------------------------------------------------
def scan_folder_recursive(folder: Path) -> List[Tuple[str, float]]:
    files = []
    for p in folder.rglob("*"):
        if not p.is_file() or p.name.startswith("."):
            continue
        if AUDIO_EXTS and p.suffix.lower() not in AUDIO_EXTS:
            continue
        files.append(p)

    # 01,02,... の先頭番号で安定ソート
    files.sort(key=lambda p: extract_leading_number(p.name))

    items: List[Tuple[str, float]] = []
    stats = {"total": 0, "ok_mutagen": 0, "ok_mdls": 0, "fail": 0}
    failed_samples: List[str] = []

    for p in files:
        stats["total"] += 1
        sec = duration_seconds_mutagen(p)
        if sec is not None:
            stats["ok_mutagen"] += 1
        else:
            sec = duration_seconds_mdls(p)
            if sec is not None:
                stats["ok_mdls"] += 1
            else:
                stats["fail"] += 1
                if len(failed_samples) < 5:
                    failed_samples.append(p.name)
                continue
        items.append((p.stem, float(sec)))

    # 診断出力
    print("\n--- Scan summary --------------------------------")
    print(f"Scanned files : {stats['total']}")
    print(f"✓ mutagen     : {stats['ok_mutagen']}")
    print(f"✓ mdls        : {stats['ok_mdls']}")
    print(f"✗ failed      : {stats['fail']}")
    if failed_samples:
        print("Examples of failed files:", ", ".join(failed_samples))
    print("-------------------------------------------------\n")

    return items

def make_chapters(items: List[Tuple[str, float]]) -> List[str]:
    chapters: List[str] = []
    t = 0.0
    for title, sec in items:
        chapters.append(f"{hhmmss_from_seconds(t)}  {title} ({hhmmss_from_seconds(sec)})")
        t += sec
    return chapters

def main() -> None:
    if not TARGET_DIR.is_dir():
        print(f"[Error] Folder not found: {TARGET_DIR}")
        return
    items = scan_folder_recursive(TARGET_DIR)
    if not items:
        print("[Info] 音源は見つかりましたが、長さが取得できませんでした。拡張子やメタデータをご確認ください。")
        print("      ・WAVでも特殊コーデック/壊れたヘッダだと取得できない場合があります。")
        print("      ・Spotlight未インデックス時は `mdimport \"<folder>\"` をお試しください。")
        return

    chapters = make_chapters(items)
    print("=== YouTube Chapters ===\n")
    print("\n".join(chapters))

if __name__ == "__main__":
    main()

関連記事

ジャーナルへ戻る