import os
import sys
import tkinter as tk
from tkinter import filedialog, ttk, messagebox
from PIL import Image, ImageTk
import io
import tempfile
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TCON, COMM, TDRC, ID3NoHeaderError
from mutagen.flac import FLAC, Picture
from mutagen import File
class AudioTagEditor:
def __init__(self, root):
self.root = root
self.root.title("音频标签编辑器")
self.root.geometry("800x650")
self.root.resizable(True, True)
# 设置中文字体支持
self.style = ttk.Style()
self.style.configure("TLabel", font=("SimHei", 10))
self.style.configure("TButton", font=("SimHei", 10))
self.style.configure("TEntry", font=("SimHei", 10))
self.style.configure("Text", font=("SimHei", 10))
# 初始化变量
self.audio_files = []
self.current_file = None
self.cover_path = None
self.cover_image = None
# 创建界面
self.create_widgets()
def create_widgets(self):
# 顶部文件选择区域
file_frame = ttk.Frame(self.root, padding="10")
file_frame.pack(fill=tk.X)
ttk.Button(file_frame, text="选择单个文件", command=self.select_single_file).pack(side=tk.LEFT, padx=5)
ttk.Button(file_frame, text="选择多个文件", command=self.select_multiple_files).pack(side=tk.LEFT, padx=5)
ttk.Button(file_frame, text="选择文件夹", command=self.select_folder).pack(side=tk.LEFT, padx=5)
self.file_listbox = tk.Listbox(self.root, height=4, font=("SimHei", 10))
self.file_listbox.pack(fill=tk.X, padx=10, pady=5)
self.file_listbox.bind('>', self.on_file_select)
# 主要内容区域(左右分栏)
content_frame = ttk.Frame(self.root, padding="10")
content_frame.pack(fill=tk.BOTH, expand=True)
# 左侧标签编辑区域
left_frame = ttk.LabelFrame(content_frame, text="标签信息", padding="10")
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
# 标签输入字段
fields = [
("标题:", "title"),
("艺术家:", "artist"),
("专辑:", "album"),
("流派:", "genre"),
("年份:", "year"),
("评论:", "comment")
]
self.entries = {}
row = 0
for label_text, key in fields:
ttk.Label(left_frame, text=label_text).grid(row=row, column=0, sticky=tk.W, pady=5)
if key == "comment":
# 评论使用多行文本框
self.entries[key] = tk.Text(left_frame, width=40, height=4)
self.entries[key].grid(row=row, column=1, sticky=tk.W+tk.E, pady=5)
# 添加滚动条
scroll = ttk.Scrollbar(left_frame, command=self.entries[key].yview)
scroll.grid(row=row, column=2, sticky=tk.N+tk.S)
self.entries[key]['yscrollcommand'] = scroll.set
else:
self.entries[key] = ttk.Entry(left_frame, width=40)
self.entries[key].grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
# 封面处理区域
ttk.Label(left_frame, text="专辑封面:").grid(row=row, column=0, sticky=tk.NW, pady=5)
cover_frame = ttk.Frame(left_frame)
cover_frame.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Button(cover_frame, text="选择封面", command=self.select_cover).pack(side=tk.TOP, pady=5)
ttk.Button(cover_frame, text="清除封面", command=self.clear_cover).pack(side=tk.TOP, pady=5)
row += 1
# 按钮区域
button_frame = ttk.Frame(left_frame)
button_frame.grid(row=row, column=0, columnspan=3, pady=10)
ttk.Button(button_frame, text="保存当前文件标签", command=self.save_current_tags).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="批量保存所有文件标签", command=self.batch_save_tags).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="从当前文件加载标签", command=self.load_current_tags).pack(side=tk.LEFT, padx=5)
# 右侧封面预览区域
right_frame = ttk.LabelFrame(content_frame, text="封面预览", padding="10")
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
self.cover_preview = ttk.Label(right_frame)
self.cover_preview.pack(fill=tk.BOTH, expand=True)
# 底部状态栏
self.status_var = tk.StringVar()
self.status_var.set("就绪")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
def select_single_file(self):
files = filedialog.askopenfilenames(
filetypes=[("音频文件", "*.mp3;*.flac"), ("所有文件", "*.*")]
)
if files:
self.audio_files = list(files)
self.update_file_list()
self.status_var.set(f"已选择 {len(self.audio_files)} 个文件")
def select_multiple_files(self):
self.select_single_file() # 功能相同,只是按钮文字不同
def select_folder(self):
folder = filedialog.askdirectory()
if folder:
audio_extensions = ('.mp3', '.flac')
self.audio_files = [
os.path.join(folder, f)
for f in os.listdir(folder)
if f.lower().endswith(audio_extensions)
]
self.update_file_list()
self.status_var.set(f"已选择文件夹,包含 {len(self.audio_files)} 个音频文件")
def update_file_list(self):
self.file_listbox.delete(0, tk.END)
for file in self.audio_files:
self.file_listbox.insert(tk.END, os.path.basename(file))
def on_file_select(self, event):
selection = self.file_listbox.curselection()
if selection:
index = selection[0]
self.current_file = self.audio_files[index]
self.load_current_tags()
self.status_var.set(f"已选择: {os.path.basename(self.current_file)}")
def select_cover(self):
cover = filedialog.askopenfilename(
filetypes=[("图片文件", "*.jpg;*.jpeg;*.png"), ("所有文件", "*.*")]
)
if cover:
self.cover_path = cover
self.update_cover_preview(cover)
def update_cover_preview(self, image_path):
try:
img = Image.open(image_path)
img.thumbnail((300, 300)) # 限制预览大小
self.cover_image = ImageTk.PhotoImage(img)
self.cover_preview.config(image=self.cover_image)
except Exception as e:
messagebox.showerror("错误", f"无法预览图片: {str(e)}")
self.cover_path = None
def clear_cover(self):
self.cover_path = None
self.cover_preview.config(image="")
self.cover_image = None
def load_current_tags(self):
if not self.current_file:
return
# 清空现有输入
for key in self.entries:
if key == "comment":
self.entries[key].delete(1.0, tk.END)
else:
self.entries[key].delete(0, tk.END)
# 清除封面预览
self.clear_cover()
try:
audio = File(self.current_file)
# 根据文件类型加载标签
if self.current_file.lower().endswith('.mp3'):
self._load_mp3_tags(audio)
elif self.current_file.lower().endswith('.flac'):
self._load_flac_tags(audio)
except Exception as e:
messagebox.showerror("错误", f"加载标签失败: {str(e)}")
def _load_mp3_tags(self, audio):
# 尝试获取ID3标签
try:
id3 = ID3(self.current_file)
# 加载文本标签
if 'TIT2' in id3:
self.entries['title'].insert(0, id3['TIT2'].text[0])
if 'TPE1' in id3:
self.entries['artist'].insert(0, id3['TPE1'].text[0])
if 'TALB' in id3:
self.entries['album'].insert(0, id3['TALB'].text[0])
if 'TCON' in id3:
self.entries['genre'].insert(0, id3['TCON'].text[0])
if 'TDRC' in id3:
self.entries['year'].insert(0, str(id3['TDRC'].text[0]))
if 'COMM' in id3:
self.entries['comment'].insert(1.0, id3['COMM'].text[0])
# 加载封面
for tag in id3.getall('APIC'):
try:
# 保存临时图片文件用于预览
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
f.write(tag.data)
temp_path = f.name
self.update_cover_preview(temp_path)
self.cover_path = temp_path # 临时文件路径
except:
continue
except ID3NoHeaderError:
# 文件没有ID3标签
pass
def _load_flac_tags(self, audio):
# 加载文本标签
if 'title' in audio:
self.entries['title'].insert(0, audio['title'][0])
if 'artist' in audio:
self.entries['artist'].insert(0, audio['artist'][0])
if 'album' in audio:
self.entries['album'].insert(0, audio['album'][0])
if 'genre' in audio:
self.entries['genre'].insert(0, audio['genre'][0])
if 'date' in audio:
self.entries['year'].insert(0, audio['date'][0])
if 'comment' in audio:
self.entries['comment'].insert(1.0, audio['comment'][0])
# 加载封面
for picture in audio.pictures:
try:
# 保存临时图片文件用于预览
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
f.write(picture.data)
temp_path = f.name
self.update_cover_preview(temp_path)
self.cover_path = temp_path # 临时文件路径
except:
continue
def save_current_tags(self):
if not self.current_file:
messagebox.showwarning("警告", "请先选择一个文件")
return
# 获取输入的标签信息
tag_data = {
'title': self.entries['title'].get(),
'artist': self.entries['artist'].get(),
'album': self.entries['album'].get(),
'genre': self.entries['genre'].get(),
'year': self.entries['year'].get(),
'comment': self.entries['comment'].get(1.0, tk.END).strip()
}
try:
if self.current_file.lower().endswith('.mp3'):
self._save_mp3_tags(self.current_file, tag_data)
elif self.current_file.lower().endswith('.flac'):
self._save_flac_tags(self.current_file, tag_data)
messagebox.showinfo("成功", f"标签已保存到 {os.path.basename(self.current_file)}")
self.status_var.set(f"已保存: {os.path.basename(self.current_file)}")
except Exception as e:
messagebox.showerror("错误", f"保存标签失败: {str(e)}")
def batch_save_tags(self):
if not self.audio_files:
messagebox.showwarning("警告", "请先选择文件")
return
# 获取输入的标签信息
tag_data = {
'title': self.entries['title'].get(),
'artist': self.entries['artist'].get(),
'album': self.entries['album'].get(),
'genre': self.entries['genre'].get(),
'year': self.entries['year'].get(),
'comment': self.entries['comment'].get(1.0, tk.END).strip()
}
success_count = 0
fail_count = 0
fail_files = []
for file in self.audio_files:
try:
if file.lower().endswith('.mp3'):
self._save_mp3_tags(file, tag_data)
elif file.lower().endswith('.flac'):
self._save_flac_tags(file, tag_data)
success_count += 1
except:
fail_count += 1
fail_files.append(os.path.basename(file))
msg = f"批量处理完成:\n成功: {success_count} 个文件\n失败: {fail_count} 个文件"
if fail_files:
msg += "\n失败文件:\n" + "\n".join(fail_files)
messagebox.showinfo("批量处理结果", msg)
self.status_var.set(f"批量处理完成: 成功 {success_count} 个, 失败 {fail_count} 个")
def _save_mp3_tags(self, file_path, tag_data):
# 尝试读取现有ID3标签,无标签则创建新ID3v2
try:
audio = ID3(file_path)
except ID3NoHeaderError:
audio = ID3() # 新建ID3标签
# 添加专辑封面(APIC帧)
if self.cover_path:
# 清除现有封面
for tag in audio.getall('APIC'):
audio.delall('APIC')
# 确定图片MIME类型
ext = os.path.splitext(self.cover_path)[1].lower()
mime = 'image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/png'
with open(self.cover_path, 'rb') as cover_file:
cover_data = cover_file.read()
audio.add(APIC(
encoding=3, # 3 = UTF-8
mime=mime,
type=3, # 3 = 封面(Front Cover)
desc=u'Cover',
data=cover_data
))
# 添加文本标签(只添加非空字段)
if tag_data['title']:
audio['TIT2'] = TIT2(encoding=3, text=tag_data['title'])
if tag_data['artist']:
audio['TPE1'] = TPE1(encoding=3, text=tag_data['artist'])
if tag_data['album']:
audio['TALB'] = TALB(encoding=3, text=tag_data['album'])
if tag_data['genre']:
audio['TCON'] = TCON(encoding=3, text=tag_data['genre'])
if tag_data['comment']:
audio['COMM'] = COMM(encoding=3, text=tag_data['comment'])
if tag_data['year']:
audio['TDRC'] = TDRC(encoding=3, text=tag_data['year'])
# 保存标签(ID3v2.3兼容性最佳)
audio.save(file_path, v2_version=3)
def _save_flac_tags(self, file_path, tag_data):
audio = FLAC(file_path)
# 添加文本标签(只添加非空字段)
if tag_data['title']:
audio['title'] = tag_data['title']
if tag_data['artist']:
audio['artist'] = tag_data['artist']
if tag_data['album']:
audio['album'] = tag_data['album']
if tag_data['genre']:
audio['genre'] = tag_data['genre']
if tag_data['comment']:
audio['comment'] = tag_data['comment']
if tag_data['year']:
audio['date'] = tag_data['year']
# 添加专辑封面
if self.cover_path:
# 清除现有封面
audio.clear_pictures()
# 确定图片MIME类型
ext = os.path.splitext(self.cover_path)[1].lower()
mime = 'image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/png'
with open(self.cover_path, 'rb') as cover_file:
cover_data = cover_file.read()
picture = Picture()
picture.data = cover_data
picture.type = 3 # 3 = 封面(Front Cover)
picture.mime = mime
picture.desc = u'Cover'
audio.add_picture(picture)
# 保存标签
audio.save()
if __name__ == "__main__":
root = tk.Tk()
app = AudioTagEditor(root)
root.mainloop()
思路很简单:用Python 处理音频元数据的主流工具(mutagen,支持 MP3、FLAC 等多种格式),来完成配置 ID3v2 标签的各类字段。
安装命令(cmd):pip install mutagen;
打包好的程序:https://wwcq.lanzouu.com/i5pAE38qcj5e 无密码,感谢支持!{:1_893:}

