-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmp3_to_csv.py
More file actions
471 lines (374 loc) · 17.9 KB
/
mp3_to_csv.py
File metadata and controls
471 lines (374 loc) · 17.9 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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MP3 → CSV 変換ツール
スクリプト配置フォルダ以下の全 .mp3 を再帰スキャンして
ID3タグ情報を CSV ファイルに書き出す
【使い方】
python3 mp3_to_csv.py
【出力】
スクリプトと同じフォルダに mp3_tags.csv を生成する
【CSV の列構成】
file_path : スクリプトからの相対パス(編集禁止)
title : 曲名 (TIT2)
artist : アーティスト (TPE1)
album : アルバム (TALB)
track : トラック番号 (TRCK)
year : 年 (TDRC / TYER)
genre : ジャンル (TCON)
album_artist: アルバムアーティスト (TPE2)
disc : ディスク番号 (TPOS)
copyright : 著作権 (TCOP)
composer : 作曲家 (TCOM)
conductor : 指揮者 (TPE3)
【設計方針】
- 編集対象フィールドのみ CSV に出力する
- COMM、APIC、PRIV 等は触らず、csv_to_mp3.py で書き戻す際にそのまま保持する
- year は ID3v2.3(TYER) と ID3v2.4(TDRC) の両方を読み、値があればどちらか一方を採用する
- file_path は相対パスで記録する(HDD を別 Mac に繋いでも動作するように)
【前提条件】
- Python 3.x
- mutagen ライブラリ(pip install mutagen)
- 仮想環境を使っている場合は source ~/mp3tag-env/bin/activate してから実行
"""
# ========== インポート ==========
import csv
import sys
from pathlib import Path
# mutagen: MP3 の ID3 タグを読み書きするライブラリ
from mutagen.id3 import ID3
from mutagen.id3 import ID3NoHeaderError
from mutagen.mp3 import MP3
# ========== 定数定義 ==========
# CSV の列名リスト(この順番で列が並ぶ)
#
# 【なぜ file_path を先頭にするか】
# 表計算アプリで開いた時に「どのファイルの情報か」が一目で分かるようにするため。
# また csv_to_mp3.py がこの列を「キー」として使うので、絶対に消さないこと。
CSV_COLUMNS = [
"file_path", # スクリプトからの相対パス ← 絶対に編集・削除しないこと
"title", # 曲名
"artist", # アーティスト
"album", # アルバム
"track", # トラック番号
"year", # 年
"genre", # ジャンル
"album_artist", # アルバムアーティスト
"disc", # ディスク番号
"copyright", # 著作権
"composer", # 作曲家
"conductor", # 指揮者
]
# 出力 CSV ファイル名
OUTPUT_CSV = "mp3_tags.csv"
# ========== 関数定義 ==========
def try_decode_shift_jis(text: str) -> str:
"""
Latin-1 として読まれた文字列を Shift-JIS として再解釈する
【なぜこの関数が必要か】
古い日本語環境(Windows XP 時代など)でリッピングされた MP3 は、
タグの文字コードが Shift-JIS で書かれていることがある。
ID3 タグの仕様では「encoding=0」は Latin-1 を意味するが、
当時の日本語リッピングソフトは encoding=0 のまま Shift-JIS バイト列を書き込んでいた。
mutagen はこれを Latin-1 として読んでしまうため、文字化けが起きる。
【処理の仕組み】
文字化けの逆操作をする:
Shift-JIS バイト列
↓ (当時のソフトが encoding=0 で書き込む)
Latin-1 として誤読(文字化け状態)
↓ (mutagen がここで読み取る)
Latin-1 で文字列に変換済みの状態(= mutagen から受け取る値)
この「Latin-1 で文字列に変換済みの状態」から元の Shift-JIS バイト列を復元するには:
1. .encode('latin-1') で一度バイト列に戻す
2. .decode('shift-jis') で Shift-JIS として読み直す
【Python初心者向け解説:encode と decode】
文字列(str)とバイト列(bytes)は Python では別の型。
- str → bytes に変換するのが encode(「エンコード」)
- bytes → str に変換するのが decode(「デコード」)
encode/decode には「どの文字コードを使うか」を指定する:
"あ".encode('utf-8') → b'\\xe3\\x81\\x82' (UTF-8 のバイト列)
"あ".encode('shift-jis') → b'\\x82\\xa0' (Shift-JIS のバイト列)
今回は:
文字化けした文字列 → Latin-1 でバイト列に戻す → Shift-JIS で読み直す
という2ステップ。
【なぜ失敗することがあるか】
本物の Latin-1 テキスト(フランス語やドイツ語など)が encoding=0 で
書かれている場合、それを Shift-JIS として読もうとすると
UnicodeDecodeError が発生する。
この例外を受け取って元の文字列をそのまま返すことで、
Latin-1 テキストを壊さないようにしている。
Args:
text: mutagen が Latin-1 として読んだ文字列(文字化けしている可能性がある)
Returns:
Shift-JIS 再解釈に成功した場合はその文字列、失敗した場合は元の文字列。
"""
try:
# ステップ1: Latin-1 としてバイト列に戻す
raw_bytes = text.encode('latin-1')
# ステップ2: Shift-JIS として読み直す
return raw_bytes.decode('shift-jis')
except (UnicodeEncodeError, UnicodeDecodeError):
# encode か decode のどちらかが失敗した場合:
# - UnicodeEncodeError: Latin-1 で表現できない文字が含まれていた
# - UnicodeDecodeError: バイト列が Shift-JIS として不正だった
# → 元の文字列をそのまま返す(壊さない)
return text
def read_text_tag(tags, frame_id: str) -> str:
"""
ID3 タグから指定フレームのテキスト値を取得する
encoding=0(Latin-1)のフレームは Shift-JIS フォールバックを試みる
【なぜこの関数が必要か】
mutagen で ID3 タグを読むと、値はただの文字列ではなく
「フレームオブジェクト」として返ってくる。
例えば TIT2 フレームを取得すると TIT2 クラスのインスタンスが返る。
そこから実際のテキスト値を取り出すには .text[0] のようにアクセスする必要がある。
また、フレームが存在しない場合は KeyError が発生するので、
それを受け取って空文字列を返す処理もここで行う。
【Shift-JIS フォールバックについて】
encoding=0 のフレームは「Latin-1 で書かれている」という意味だが、
古い日本語ソフトが encoding=0 のまま Shift-JIS で書き込んだファイルが存在する。
そのようなファイルはそのまま読むと文字化けするため、
encoding=0 の場合は try_decode_shift_jis() を通して再解釈を試みる。
【Python初心者向け解説:例外処理(try / except)とは】
プログラムを実行中にエラーが起きると、通常はそこで処理が止まってしまう。
try / except を使うと「エラーが起きても止まらず、別の処理をする」ことができる。
try:
# エラーが起きるかもしれない処理
何かの処理()
except エラーの種類:
# エラーが起きた時の処理
代わりの処理()
今回は「フレームが存在しない(KeyError)」というエラーを受け取って、
空文字列を返すようにしている。
Args:
tags : mutagen が返す ID3 タグオブジェクト
frame_id : フレームID(例:"TIT2"、"TPE1")
Returns:
テキスト値の文字列。フレームが存在しない場合は空文字列 ""。
"""
try:
# tags[frame_id] でフレームオブジェクトを取得
# .text はテキストのリスト(複数値を持てる仕様のため)
# [0] で最初の値を取得
frame = tags[frame_id]
text = str(frame.text[0]).strip()
# encoding=0(Latin-1)の場合、Shift-JIS の可能性がある
# try_decode_shift_jis() で再解釈を試みる
# encoding が 1(UTF-16)、2(UTF-16BE)、3(UTF-8)の場合はそのまま返す
#
# 【Python初心者向け解説:frame.encoding とは】
# ID3 タグの各テキストフレームには、テキスト本体の他に
# 「このテキストはどの文字コードで書かれているか」を示す encoding 属性がある。
# 値は整数で、0=Latin-1、1=UTF-16、2=UTF-16BE、3=UTF-8。
if frame.encoding == 0:
text = try_decode_shift_jis(text)
return text
except (KeyError, IndexError):
# KeyError : そのフレームIDが存在しない場合
# IndexError: .text が空リストだった場合(稀だが念のため)
return ""
def read_year(tags) -> str:
"""
年情報を取得する(ID3v2.3 の TYER と ID3v2.4 の TDRC の両方に対応)
【なぜ year だけ特別扱いするか】
ID3 タグのバージョンによって「年」を表すフレームIDが違う:
- ID3v2.3 → TYER(4桁の年のみ。例:"2003")
- ID3v2.4 → TDRC(日付も入れられる。例:"2003-04-15" や "2003")
手元のライブラリには両方が混在しているため、
どちらが入っていても正しく読めるようにする。
【優先順位】
1. TDRC があればそれを使う(v2.4 の方が新しい仕様のため)
2. TDRC がなければ TYER を使う
3. どちらもなければ空文字列を返す
Args:
tags: mutagen が返す ID3 タグオブジェクト
Returns:
年を表す文字列。例:"2003"、"2003-04-15"。なければ空文字列。
"""
# まず TDRC(v2.4)を試みる
tdrc = read_text_tag(tags, "TDRC")
if tdrc:
return tdrc
# TDRC がなければ TYER(v2.3)を試みる
tyer = read_text_tag(tags, "TYER")
if tyer:
return tyer
# どちらもなければ空文字列
return ""
def extract_tags(mp3_path: Path, base_dir: Path) -> dict:
"""
1つの .mp3 ファイルからタグ情報を読み取り、辞書として返す
【この関数の役割】
- ID3 タグを開く
- 各フレームの値を読み取る
- CSV の列名をキーとした辞書を返す
【返り値の形式】
{
"file_path": "Artist/Album/01 - Title.mp3",
"title": "Title",
"artist": "Artist",
...
}
Args:
mp3_path : 処理対象の .mp3 ファイルの Path オブジェクト
base_dir : 相対パス計算の基準となるディレクトリ
Returns:
CSV の列名をキーとした辞書。タグが読めない場合も空文字列で埋めて返す。
"""
# 相対パスを計算
# 例:base_dir = /Volumes/HDD-02/MP3/
# mp3_path = /Volumes/HDD-02/MP3/Artist/Album/01.mp3
# → relative_path = Artist/Album/01.mp3
try:
relative_path = mp3_path.relative_to(base_dir)
file_path_str = str(relative_path)
except ValueError:
# relative_to が失敗する場合(通常は起きないが念のため)
file_path_str = str(mp3_path)
# まず全列を空文字列で初期化した辞書を作る
# 【Python初心者向け解説:辞書内包表記】
# {キー: 値 for キー in リスト} という書き方で辞書を作れる
# ここでは CSV_COLUMNS の各列名をキーに、空文字列を値にした辞書を作っている
row = {col: "" for col in CSV_COLUMNS}
# file_path だけ先に設定(タグが読めなくても必ず入れる)
row["file_path"] = file_path_str
try:
# ID3 タグを読み込む
tags = ID3(mp3_path)
# 各フィールドを読み取って辞書に格納
row["title"] = read_text_tag(tags, "TIT2")
row["artist"] = read_text_tag(tags, "TPE1")
row["album"] = read_text_tag(tags, "TALB")
row["track"] = read_text_tag(tags, "TRCK")
row["year"] = read_year(tags)
row["genre"] = read_text_tag(tags, "TCON")
row["album_artist"] = read_text_tag(tags, "TPE2")
row["disc"] = read_text_tag(tags, "TPOS")
row["copyright"] = read_text_tag(tags, "TCOP")
row["composer"] = read_text_tag(tags, "TCOM")
row["conductor"] = read_text_tag(tags, "TPE3")
except ID3NoHeaderError:
# ID3 タグが存在しない .mp3 ファイル
# file_path だけ入れて、残りは全部空文字列のまま返す
pass
except Exception as e:
# その他の予期しないエラー
# エラーの内容を title 列に記録しておく(後で確認できるように)
row["title"] = f"[READ ERROR: {e}]"
return row
def mp3_to_csv(base_dir: Path, output_path: Path):
"""
base_dir 以下の全 .mp3 を再帰スキャンして CSV に書き出す
【処理の流れ】
1. .mp3 ファイルを再帰検索
2. 各ファイルのタグを読み取る
3. CSV ファイルに書き出す
4. 処理結果をサマリー表示
Args:
base_dir : スキャン対象のベースディレクトリ
output_path : 出力 CSV ファイルのパス
"""
# ---- .mp3 ファイルを再帰検索 ----
print(f"スキャン対象: {base_dir.resolve()}")
print(f"出力先 : {output_path.resolve()}")
print()
print("ファイルを検索中...", end="", flush=True)
# 大文字小文字両方に対応
mp3_files = sorted(set(
list(base_dir.rglob("*.mp3")) +
list(base_dir.rglob("*.MP3"))
))
total = len(mp3_files)
print(f" {total:,} 件\n")
if total == 0:
print("⚠️ .mp3 ファイルが見つかりませんでした")
return
# ---- CSV を書き出す ----
# 統計カウンター
success_count = 0
no_tag_count = 0
error_count = 0
# newline="" の理由:
# Python の csv モジュールは自前で改行を処理する。
# newline="" を指定しないと、Windows 環境で改行が二重になることがある。
# Mac でも習慣として指定しておく。
#
# encoding="utf-8-sig" の理由:
# utf-8-sig は UTF-8 の先頭に BOM(バイト順マーク)を付ける形式。
# Mac の Numbers や Windows の Excel で開いた時に文字化けしにくい。
# BOM なし UTF-8 だと Excel が文字化けすることがある。
with open(output_path, "w", newline="", encoding="utf-8-sig") as csvfile:
# csv.DictWriter:辞書を CSV の行として書き出すクラス
# fieldnames に列名のリストを渡すと、その順番で列が並ぶ
writer = csv.DictWriter(csvfile, fieldnames=CSV_COLUMNS)
# ヘッダー行(列名の行)を書き出す
writer.writeheader()
# 各ファイルを処理
for i, mp3_path in enumerate(mp3_files, 1):
# 進捗表示(100件ごと、または最後)
if i % 100 == 0 or i == total:
print(f"\r 進捗: {i:,}/{total:,}", end="", flush=True)
# タグを読み取る
row = extract_tags(mp3_path, base_dir)
# 統計カウント
if "[READ ERROR:" in row.get("title", ""):
error_count += 1
elif all(row[col] == "" for col in CSV_COLUMNS if col != "file_path"):
# file_path 以外が全部空 = タグなしファイル
no_tag_count += 1
else:
success_count += 1
# CSV に1行書き出す
writer.writerow(row)
# 進捗表示の後に改行
print("\n")
# ---- サマリー表示 ----
print("=" * 60)
print("完了")
print("=" * 60)
print(f"総ファイル数 : {total:,} 件")
print(f"タグあり : {success_count:,} 件")
print(f"タグなし : {no_tag_count:,} 件")
print(f"読み込みエラー: {error_count:,} 件")
print(f"\n出力先: {output_path.resolve()}")
print("=" * 60)
print()
print("【次のステップ】")
print("1. CSV を表計算アプリ(Numbers / Excel 等)で開いて編集")
print("2. 編集後、CSV のまま保存(形式を変えないこと)")
print("3. python3 csv_to_mp3.py を実行してタグを書き戻す")
# ========== エントリーポイント ==========
def main():
"""
メイン関数
【使い方】
python3 mp3_to_csv.py
引数なしで実行するとスクリプトと同じフォルダをスキャンする。
別のフォルダを指定したい場合:
python3 mp3_to_csv.py /Volumes/HDD-02/MP3
出力先を指定したい場合:
python3 mp3_to_csv.py /Volumes/HDD-02/MP3 /path/to/output.csv
"""
# スキャン対象ディレクトリの決定
if len(sys.argv) >= 2:
base_dir = Path(sys.argv[1])
else:
# 引数なし → スクリプト自身が置かれているディレクトリ
base_dir = Path(__file__).parent
# 出力 CSV パスの決定
if len(sys.argv) >= 3:
output_path = Path(sys.argv[2])
else:
# スクリプトと同じフォルダに出力
output_path = Path(__file__).parent / OUTPUT_CSV
# ディレクトリの存在確認
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)
# 実行
mp3_to_csv(base_dir, output_path)
if __name__ == "__main__":
main()