-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalyze_mp3.py
More file actions
501 lines (418 loc) · 18.2 KB
/
analyze_mp3.py
File metadata and controls
501 lines (418 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MP3 ID3タグ分析ツール
スクリプト配置フォルダ以下の全 .mp3 を再帰スキャンして統計を出す
【目的】
- 手元の .mp3 ライブラリにどんなID3タグが入っているか把握する
- どのフィールドが何件埋まっているか確認する
- ID3タグのバージョン分布を確認する
- 後続スクリプト(mp3_to_csv.py, csv_to_mp3.py)の設計に使う
【使い方】
python3 analyze_mp3.py
【出力】
- ターミナルに統計サマリーを表示
- analyze_mp3_result.txt に同じ内容を保存
【前提条件】
- Python 3.x
- mutagen ライブラリ(pip install mutagen)
- このスクリプトと同じフォルダ以下に .mp3 ファイルが存在すること
"""
# ========== インポート ==========
import sys
from pathlib import Path
from collections import Counter, defaultdict
# mutagen: Python でメディアファイルのメタデータを扱うライブラリ
# pip install mutagen でインストール
from mutagen.id3 import ID3
from mutagen.mp3 import MP3
from mutagen.id3 import ID3NoHeaderError
# ========== 定数定義 ==========
# 【ID3タグのフィールド一覧】
# ID3タグのフィールドは "フレームID" という4文字の英数字で管理されている
# 例:TIT2 = 曲名、TPE1 = アーティスト名
#
# ここでは「人間が読める名前」と「フレームID」の対応表を作っておく
# 後続スクリプトのCSV列名にもそのまま使う予定
#
# 参考:ID3v2.3 仕様 https://id3.org/id3v2.3.0
ID3_FIELDS = {
# ------ よく使うフィールド ------
"TIT2": "曲名",
"TPE1": "アーティスト",
"TALB": "アルバム",
"TRCK": "トラック番号",
"TDRC": "録音年(v2.4)", # ID3v2.4 用
"TYER": "年(v2.3)", # ID3v2.3 用(TDRCと重複することがある)
"TCON": "ジャンル",
"TPE2": "アルバムアーティスト",
"TIT1": "グループ/コンテンツグループ",
"TIT3": "サブタイトル",
"TPOS": "ディスク番号",
"TBPM": "BPM",
"TKEY": "初期キー",
"TLAN": "言語",
"TLEN": "曲の長さ(ミリ秒)",
"TMED": "メディアタイプ",
"TOAL": "オリジナルアルバム",
"TOFN": "オリジナルファイル名",
"TOLY": "オリジナル作詞家",
"TOPE": "オリジナルアーティスト",
"TORY": "オリジナルリリース年",
"TOWN": "ファイル所有者",
"TPE3": "指揮者",
"TPE4": "リミックス/編曲",
"TPUB": "パブリッシャー",
"TRDA": "録音日",
"TRSN": "インターネットラジオ局名",
"TRSO": "インターネットラジオ局オーナー",
"TSIZ": "ファイルサイズ",
"TSRC": "ISRC(国際標準レコーディングコード)",
"TSSE": "エンコードに使われたソフトウェア/ハードウェア",
"TENC": "エンコーダー",
"TCOP": "著作権",
"TCOM": "作曲家",
"TEXT": "作詞家",
"TFLT": "ファイルタイプ",
"TIME": "時刻",
"TDAT": "日付",
# ------ コメント・歌詞 ------
"COMM": "コメント",
"USLT": "歌詞(同期なし)",
"SYLT": "歌詞(同期あり)",
# ------ URL系 ------
"WCOM": "商業情報URL",
"WCOP": "著作権URL",
"WOAF": "公式オーディオファイルURL",
"WOAR": "公式アーティストURL",
"WOAS": "公式オーディオソースURL",
"WORS": "公式インターネットラジオURL",
"WPAY": "支払いURL",
"WPUB": "公式パブリッシャーURL",
"WXXX": "カスタムURL",
# ------ 画像 ------
"APIC": "添付画像(アートワーク)",
# ------ その他 ------
"TXXX": "カスタムテキストフレーム",
"PRIV": "プライベートフレーム",
"UFID": "ユニークファイル識別子",
"PCNT": "再生回数",
"POPM": "ポップメーター(評価)",
"GEOB": "一般カプセル化オブジェクト",
"MCDI": "ミュージックCDID",
"ETCO": "イベントタイミングコード",
"MLLT": "MPEGロケーションルックアップテーブル",
"SYTC": "同期テンポコード",
"RVRB": "リバーブ",
"AENC": "オーディオ暗号化",
"LINK": "リンクフレーム",
"POSS": "位置同期フレーム",
"USER": "利用規約",
"OWNE": "所有権フレーム",
"COMR": "商業フレーム",
"ENCR": "暗号化メソッド登録",
"GRID": "グループ識別登録",
"SIGN": "署名フレーム",
"SEEK": "シークフレーム",
"RVA2": "相対ボリューム調整(v2.4)",
"RVAD": "相対ボリューム調整(v2.3)",
"EQU2": "イコライゼーション(v2.4)",
"EQUA": "イコライゼーション(v2.3)",
"CHAP": "チャプター",
"CTOC": "目次",
"ASPI": "オーディオシークポイントインデックス",
"IPLS": "関与者リスト(v2.3)",
"TIPL": "関与者リスト(v2.4)",
"TMCL": "ミュージシャンクレジットリスト(v2.4)",
"TFLT": "ファイルタイプ",
"TSOT": "ソート用曲名(v2.4)",
"TSOA": "ソート用アルバム名(v2.4)",
"TSOP": "ソート用アーティスト名(v2.4)",
"TSO2": "ソート用アルバムアーティスト名(非標準)",
"TSOC": "ソート用作曲家名(非標準)",
}
# ========== メイン処理 ==========
def analyze_mp3_library(base_dir: Path) -> dict:
"""
指定ディレクトリ以下の全 .mp3 を再帰スキャンして統計データを返す
【処理の流れ】
1. rglob("*.mp3") で全 .mp3 を再帰検索
2. 各ファイルの ID3 タグを読み込む
3. フレームIDの出現回数をカウント
4. ID3バージョン、エンコーディング等の統計を収集
Args:
base_dir: スキャン対象のベースディレクトリ
Returns:
統計データを格納した辞書
"""
# ---- 統計変数の初期化 ----
# 処理したファイル数
total_files = 0
# ID3タグが存在しなかったファイル数
no_tag_files = 0
# 読み込みエラーが発生したファイル数
error_files = 0
# エラーが発生したファイルの情報を記録するリスト
# 例:[{"path": "...", "error": "..."}, ...]
error_list = []
# フレームIDの出現回数カウンター
# Counter は「何が何回出たか」を自動的に数えてくれる Python の便利なクラス
# 例:Counter({"TIT2": 1200, "TPE1": 1150, ...})
frame_counter = Counter()
# ID3バージョンの分布
# 例:Counter({"ID3v2.3": 800, "ID3v2.4": 400, ...})
id3_version_counter = Counter()
# フォルダの深さの分布
# 「このスクリプトから .mp3 ファイルまで何階層あるか」の統計
# 例:Counter({4: 600, 3: 400, 5: 200})
depth_counter = Counter()
# 未知のフレームID(ID3_FIELDS に載っていないもの)の出現カウンター
# 手元のライブラリに独自タグが入っているかどうかの確認に使う
unknown_frame_counter = Counter()
# ファイルサイズの合計(バイト)
total_size_bytes = 0
# ---- .mp3 ファイルを再帰検索 ----
# rglob("*.mp3") は「再帰的なワイルドカード検索」
# base_dir 以下のすべてのサブフォルダを含めて *.mp3 を探す
# 大文字小文字が混在する場合を考慮して、.MP3 も別途検索する
mp3_files = list(base_dir.rglob("*.mp3")) + list(base_dir.rglob("*.MP3"))
# 重複を除去(同じファイルが両方のリストに入ることはないはずだが念のため)
# set() で重複を除き、list() で戻す
mp3_files = list(set(mp3_files))
# ファイルパスでソート(出力を見やすくするため)
mp3_files.sort()
total_files = len(mp3_files)
if total_files == 0:
# ファイルが見つからなかった場合は早期リターン
return {
"total_files": 0,
"no_tag_files": 0,
"error_files": 0,
"error_list": [],
"frame_counter": Counter(),
"id3_version_counter": Counter(),
"depth_counter": Counter(),
"unknown_frame_counter": Counter(),
"total_size_bytes": 0,
}
# ---- 各ファイルを処理 ----
print(f"スキャン中... {total_files} ファイルを処理します")
print("(しばらく時間がかかる場合があります)\n")
for i, mp3_path in enumerate(mp3_files, 1):
# 進捗表示(100件ごと、または最後のファイル)
# end="" で改行しない、\r で行頭に戻る(同じ行を上書き表示)
if i % 100 == 0 or i == total_files:
print(f"\r 進捗: {i}/{total_files}", end="", flush=True)
try:
# ---- ファイルサイズを記録 ----
total_size_bytes += mp3_path.stat().st_size
# ---- フォルダの深さを計算 ----
# base_dir から mp3_path までの相対パスのパーツ数 = 深さ
# 例:base_dir=script/, mp3_path=script/A/artist/album/title.mp3
# → relative_parts = ("A", "artist", "album", "title.mp3") → 深さ4
try:
relative_path = mp3_path.relative_to(base_dir)
# parts は ("A", "artist", "album", "title.mp3") のようなタプル
# len() でパーツ数を取得(= 深さ)
depth = len(relative_path.parts)
depth_counter[depth] += 1
except ValueError:
# relative_to が失敗する場合(通常は起きないが念のため)
pass
# ---- ID3タグを読み込む ----
try:
# ID3() でファイルから ID3 タグを読み込む
# ID3NoHeaderError は「ID3タグが存在しない」場合に発生する例外
tags = ID3(mp3_path)
except ID3NoHeaderError:
# ID3タグが存在しない .mp3 ファイル
no_tag_files += 1
id3_version_counter["タグなし"] += 1
continue # 次のファイルへ
# ---- ID3バージョンを記録 ----
# tags.version は (2, 3, 0) のようなタプルで返ってくる
# 例:ID3v2.3.0 → (2, 3, 0)
version_tuple = tags.version
# f-string で "ID3v2.3" のような文字列に整形
version_str = f"ID3v{version_tuple[0]}.{version_tuple[1]}"
id3_version_counter[version_str] += 1
# ---- 各フレームIDをカウント ----
# tags.keys() でこのファイルに存在する全フレームIDを取得
# 例:["TIT2", "TPE1", "TALB", "APIC", ...]
for frame_id in tags.keys():
# フレームIDをカウント
frame_counter[frame_id] += 1
# 未知のフレームIDかどうか確認
# ID3_FIELDS に載っていないフレームIDは「未知」として記録
if frame_id not in ID3_FIELDS:
unknown_frame_counter[frame_id] += 1
except Exception as e:
# 予期しないエラーが発生した場合でも処理を続行
error_files += 1
error_list.append({
"path": str(mp3_path),
"error": str(e)
})
continue
# 進捗表示の後に改行
print("\n")
# ---- 結果を辞書にまとめて返す ----
return {
"total_files": total_files,
"no_tag_files": no_tag_files,
"error_files": error_files,
"error_list": error_list,
"frame_counter": frame_counter,
"id3_version_counter": id3_version_counter,
"depth_counter": depth_counter,
"unknown_frame_counter": unknown_frame_counter,
"total_size_bytes": total_size_bytes,
}
def format_size(size_bytes: int) -> str:
"""
バイト数を人間が読みやすい形式に変換する
例:
1024 → "1.0 KB"
1048576 → "1.0 MB"
1073741824 → "1.0 GB"
Args:
size_bytes: バイト数
Returns:
整形された文字列
"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 ** 2:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 ** 3:
return f"{size_bytes / 1024**2:.1f} MB"
else:
return f"{size_bytes / 1024**3:.2f} GB"
def print_report(stats: dict, base_dir: Path) -> str:
"""
統計データを整形して表示し、文字列としても返す
Args:
stats: analyze_mp3_library() が返した統計辞書
base_dir: スキャンしたベースディレクトリ
Returns:
レポートの文字列(ファイル保存用)
"""
# レポートを文字列として構築しながら表示する
# lines リストに行を追加していき、最後に結合する
lines = []
def p(text=""):
"""print と lines への追加を同時に行うヘルパー関数"""
print(text)
lines.append(text)
# ---- ヘッダー ----
p("=" * 70)
p("MP3 ID3タグ 分析レポート")
p("=" * 70)
p(f"スキャン対象: {base_dir.resolve()}")
p()
# ---- 基本統計 ----
total = stats["total_files"]
if total == 0:
p("⚠️ .mp3 ファイルが見つかりませんでした")
p("スクリプトと同じフォルダ(またはサブフォルダ)に .mp3 を置いてください")
return "\n".join(lines)
p("【基本統計】")
p(f" 総ファイル数 : {total:,} 件")
p(f" タグあり : {total - stats['no_tag_files'] - stats['error_files']:,} 件")
p(f" タグなし : {stats['no_tag_files']:,} 件")
p(f" 読み込みエラー : {stats['error_files']:,} 件")
p(f" 総ファイルサイズ: {format_size(stats['total_size_bytes'])}")
p()
# ---- ID3バージョン分布 ----
p("【ID3バージョン分布】")
for version, count in sorted(stats["id3_version_counter"].items(),
key=lambda x: -x[1]):
pct = count / total * 100
p(f" {version:<20s}: {count:>6,} 件 ({pct:5.1f}%)")
p()
# ---- フォルダ深さ分布 ----
p("【フォルダ深さ分布】")
p(" (スクリプト直下からの階層数)")
for depth, count in sorted(stats["depth_counter"].items()):
pct = count / total * 100
# 例示パス
example = "/".join(["フォルダ"] * (depth - 1) + ["ファイル.mp3"])
p(f" 深さ {depth:2d} ({example[:40]:<40s}): {count:>6,} 件 ({pct:5.1f}%)")
p()
# ---- フレームID出現統計 ----
p("【フレームID出現統計】")
p(" (全ファイル中、そのフレームIDが存在するファイルの割合)")
p()
p(f" {'フレームID':<8} {'説明':<30} {'件数':>7} {'割合':>7}")
p(f" {'-'*8} {'-'*30} {'-'*7} {'-'*7}")
# 出現回数が多い順にソート
for frame_id, count in stats["frame_counter"].most_common():
pct = count / total * 100
# ID3_FIELDS に説明があれば表示、なければ "(不明)" と表示
description = ID3_FIELDS.get(frame_id, "(不明/カスタム)")
p(f" {frame_id:<8} {description:<30} {count:>7,} ({pct:5.1f}%)")
p()
# ---- 未知フレームID ----
if stats["unknown_frame_counter"]:
p("【未知/カスタムフレームID一覧】")
p(" (ID3_FIELDS 定義外のフレームID)")
for frame_id, count in stats["unknown_frame_counter"].most_common():
pct = count / total * 100
p(f" {frame_id:<8} : {count:>6,} 件 ({pct:5.1f}%)")
p()
else:
p("【未知/カスタムフレームID】")
p(" なし(全フレームIDが既知)")
p()
# ---- エラーファイル一覧 ----
if stats["error_files"] > 0:
p("【読み込みエラーファイル一覧】")
for err in stats["error_list"][:20]: # 最大20件表示
p(f" {err['path']}")
p(f" エラー: {err['error']}")
if len(stats["error_list"]) > 20:
p(f" ... 他 {len(stats['error_list']) - 20} 件")
p()
p("=" * 70)
p("分析完了")
p("=" * 70)
return "\n".join(lines)
def main():
"""
メイン関数
【使い方】
python3 analyze_mp3.py
スクリプトが置かれているフォルダ以下を再帰的にスキャンします。
別のフォルダを指定したい場合は、コマンドライン引数で渡せます:
python3 analyze_mp3.py /path/to/music/folder
"""
# スキャン対象ディレクトリの決定
if len(sys.argv) >= 2:
# コマンドライン引数でパスが指定された場合
base_dir = Path(sys.argv[1])
else:
# 引数なし → スクリプト自身が置かれているディレクトリ
# Path(__file__) はこのスクリプト自体のパス
# .parent でスクリプトが入っているフォルダを取得
base_dir = Path(__file__).parent
# ディレクトリの存在確認
if not base_dir.exists():
print(f"エラー: ディレクトリが見つかりません: {base_dir}")
sys.exit(1)
if not base_dir.is_dir():
print(f"エラー: ディレクトリではありません: {base_dir}")
sys.exit(1)
# 分析実行
stats = analyze_mp3_library(base_dir)
# レポート表示 & 文字列取得
report_text = print_report(stats, base_dir)
# レポートをファイルに保存
# スクリプトと同じフォルダに analyze_mp3_result.txt として保存
output_path = Path(__file__).parent / "analyze_mp3_result.txt"
with open(output_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\nレポートを保存しました: {output_path}")
# このスクリプトが直接実行された場合のみ main() を呼び出す
# 他のスクリプトから import された場合は実行されない
if __name__ == "__main__":
main()