280 lines
10 KiB
Text
280 lines
10 KiB
Text
"""ID3 tagging service using mutagen"""
|
|
import os
|
|
import logging
|
|
from mutagen.mp4 import MP4, MP4Cover
|
|
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TXXX
|
|
from mutagen.flac import FLAC, Picture
|
|
from typing import Optional, Dict, Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ID3TagService:
|
|
"""Service for reading and writing ID3 tags"""
|
|
|
|
@staticmethod
|
|
def read_tags(file_path: str) -> Dict[str, Any]:
|
|
"""
|
|
Read ID3 tags from audio file
|
|
|
|
Args:
|
|
file_path: Path to audio file
|
|
|
|
Returns:
|
|
Dictionary with tag information
|
|
"""
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
logger.error(f"File not found: {file_path}")
|
|
return {}
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
tags = {}
|
|
|
|
if ext == '.m4a' or ext == '.mp4':
|
|
audio = MP4(file_path)
|
|
tags = {
|
|
'title': audio.get('\xa9nam', [''])[0],
|
|
'artist': audio.get('\xa9ART', [''])[0],
|
|
'album': audio.get('\xa9alb', [''])[0],
|
|
'album_artist': audio.get('aART', [''])[0],
|
|
'year': audio.get('\xa9day', [''])[0],
|
|
'genre': audio.get('\xa9gen', [''])[0],
|
|
'track_number': audio.get('trkn', [(0, 0)])[0][0],
|
|
'disc_number': audio.get('disk', [(0, 0)])[0][0],
|
|
'duration': audio.info.length if audio.info else 0,
|
|
'bitrate': audio.info.bitrate if audio.info else 0,
|
|
'has_cover': 'covr' in audio,
|
|
}
|
|
|
|
elif ext == '.mp3':
|
|
audio = ID3(file_path)
|
|
tags = {
|
|
'title': str(audio.get('TIT2', '')),
|
|
'artist': str(audio.get('TPE1', '')),
|
|
'album': str(audio.get('TALB', '')),
|
|
'album_artist': str(audio.get('TPE2', '')),
|
|
'year': str(audio.get('TDRC', '')),
|
|
'genre': str(audio.get('TCON', '')),
|
|
'track_number': str(audio.get('TRCK', '')).split('/')[0] if audio.get('TRCK') else '',
|
|
'disc_number': str(audio.get('TPOS', '')).split('/')[0] if audio.get('TPOS') else '',
|
|
'has_cover': 'APIC:' in audio or any(k.startswith('APIC') for k in audio.keys()),
|
|
}
|
|
|
|
elif ext == '.flac':
|
|
audio = FLAC(file_path)
|
|
tags = {
|
|
'title': audio.get('title', [''])[0],
|
|
'artist': audio.get('artist', [''])[0],
|
|
'album': audio.get('album', [''])[0],
|
|
'album_artist': audio.get('albumartist', [''])[0],
|
|
'year': audio.get('date', [''])[0],
|
|
'genre': audio.get('genre', [''])[0],
|
|
'track_number': audio.get('tracknumber', [''])[0],
|
|
'disc_number': audio.get('discnumber', [''])[0],
|
|
'duration': audio.info.length if audio.info else 0,
|
|
'has_cover': len(audio.pictures) > 0,
|
|
}
|
|
|
|
return tags
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error reading tags from {file_path}: {e}")
|
|
return {}
|
|
|
|
@staticmethod
|
|
def write_tags(file_path: str, tags: Dict[str, Any]) -> bool:
|
|
"""
|
|
Write ID3 tags to audio file
|
|
|
|
Args:
|
|
file_path: Path to audio file
|
|
tags: Dictionary with tag information
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
logger.error(f"File not found: {file_path}")
|
|
return False
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
if ext == '.m4a' or ext == '.mp4':
|
|
audio = MP4(file_path)
|
|
|
|
if 'title' in tags:
|
|
audio['\xa9nam'] = tags['title']
|
|
if 'artist' in tags:
|
|
audio['\xa9ART'] = tags['artist']
|
|
if 'album' in tags:
|
|
audio['\xa9alb'] = tags['album']
|
|
if 'album_artist' in tags:
|
|
audio['aART'] = tags['album_artist']
|
|
if 'year' in tags:
|
|
audio['\xa9day'] = str(tags['year'])
|
|
if 'genre' in tags:
|
|
audio['\xa9gen'] = tags['genre']
|
|
if 'track_number' in tags:
|
|
audio['trkn'] = [(int(tags['track_number']), 0)]
|
|
if 'disc_number' in tags:
|
|
audio['disk'] = [(int(tags['disc_number']), 0)]
|
|
|
|
audio.save()
|
|
|
|
elif ext == '.mp3':
|
|
try:
|
|
audio = ID3(file_path)
|
|
except:
|
|
audio = ID3()
|
|
|
|
if 'title' in tags:
|
|
audio['TIT2'] = TIT2(encoding=3, text=tags['title'])
|
|
if 'artist' in tags:
|
|
audio['TPE1'] = TPE1(encoding=3, text=tags['artist'])
|
|
if 'album' in tags:
|
|
audio['TALB'] = TALB(encoding=3, text=tags['album'])
|
|
if 'year' in tags:
|
|
audio['TDRC'] = TDRC(encoding=3, text=str(tags['year']))
|
|
if 'genre' in tags:
|
|
audio['TCON'] = TCON(encoding=3, text=tags['genre'])
|
|
if 'track_number' in tags:
|
|
audio['TRCK'] = TRCK(encoding=3, text=str(tags['track_number']))
|
|
|
|
audio.save(file_path)
|
|
|
|
elif ext == '.flac':
|
|
audio = FLAC(file_path)
|
|
|
|
if 'title' in tags:
|
|
audio['title'] = tags['title']
|
|
if 'artist' in tags:
|
|
audio['artist'] = tags['artist']
|
|
if 'album' in tags:
|
|
audio['album'] = tags['album']
|
|
if 'album_artist' in tags:
|
|
audio['albumartist'] = tags['album_artist']
|
|
if 'year' in tags:
|
|
audio['date'] = str(tags['year'])
|
|
if 'genre' in tags:
|
|
audio['genre'] = tags['genre']
|
|
if 'track_number' in tags:
|
|
audio['tracknumber'] = str(tags['track_number'])
|
|
if 'disc_number' in tags:
|
|
audio['discnumber'] = str(tags['disc_number'])
|
|
|
|
audio.save()
|
|
|
|
logger.info(f"Successfully wrote tags to {file_path}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error writing tags to {file_path}: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def embed_cover_art(file_path: str, image_data: bytes, mime_type: str = 'image/jpeg') -> bool:
|
|
"""
|
|
Embed cover art into audio file
|
|
|
|
Args:
|
|
file_path: Path to audio file
|
|
image_data: Image binary data
|
|
mime_type: MIME type of image
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
logger.error(f"File not found: {file_path}")
|
|
return False
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
if ext == '.m4a' or ext == '.mp4':
|
|
audio = MP4(file_path)
|
|
|
|
if mime_type == 'image/png':
|
|
cover_format = MP4Cover.FORMAT_PNG
|
|
else:
|
|
cover_format = MP4Cover.FORMAT_JPEG
|
|
|
|
audio['covr'] = [MP4Cover(image_data, imageformat=cover_format)]
|
|
audio.save()
|
|
|
|
elif ext == '.mp3':
|
|
try:
|
|
audio = ID3(file_path)
|
|
except:
|
|
audio = ID3()
|
|
|
|
audio['APIC'] = APIC(
|
|
encoding=3,
|
|
mime=mime_type,
|
|
type=3, # Cover (front)
|
|
desc='Cover',
|
|
data=image_data
|
|
)
|
|
audio.save(file_path)
|
|
|
|
elif ext == '.flac':
|
|
audio = FLAC(file_path)
|
|
|
|
picture = Picture()
|
|
picture.type = 3 # Cover (front)
|
|
picture.mime = mime_type
|
|
picture.desc = 'Cover'
|
|
picture.data = image_data
|
|
|
|
audio.clear_pictures()
|
|
audio.add_picture(picture)
|
|
audio.save()
|
|
|
|
logger.info(f"Successfully embedded cover art in {file_path}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error embedding cover art in {file_path}: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def extract_cover_art(file_path: str) -> Optional[bytes]:
|
|
"""
|
|
Extract cover art from audio file
|
|
|
|
Args:
|
|
file_path: Path to audio file
|
|
|
|
Returns:
|
|
Image binary data or None
|
|
"""
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
return None
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
if ext == '.m4a' or ext == '.mp4':
|
|
audio = MP4(file_path)
|
|
covers = audio.get('covr', [])
|
|
if covers:
|
|
return bytes(covers[0])
|
|
|
|
elif ext == '.mp3':
|
|
audio = ID3(file_path)
|
|
for key in audio.keys():
|
|
if key.startswith('APIC'):
|
|
return audio[key].data
|
|
|
|
elif ext == '.flac':
|
|
audio = FLAC(file_path)
|
|
if audio.pictures:
|
|
return audio.pictures[0].data
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting cover art from {file_path}: {e}")
|
|
return None
|