Source code for modules.core.metadata

#!/usr/bin/env python3
"""
Metadata Editor Tool
Copyright (c) 2025 TAPS OSS
Project: https://github.com/TAPSOSS/Walrio
Licensed under the BSD-3-Clause License (see LICENSE file for details)

A tool to modify audio file metadata (tags, album art, etc.) using mutagen CLI tools.
Supports all major audio formats including MP3, FLAC, OGG, MP4, OPUS, and more.
"""

import os
import sys
import argparse
import logging
import subprocess
import base64
import io
import re
from pathlib import Path
from typing import List, Dict, Any, Optional

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('MetadataEditor')

# Import mutagen directly
try:
    from mutagen import File as MutagenFile
    from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TPE2, TDRC, TCON, TRCK, TPOS, COMM
    from mutagen.mp3 import MP3
    from mutagen.flac import FLAC, Picture
    from mutagen.oggvorbis import OggVorbis
    from mutagen.oggopus import OggOpus
    from mutagen.mp4 import MP4
    MUTAGEN_AVAILABLE = True
except ImportError:
    MUTAGEN_AVAILABLE = False

# Import Pillow for image processing
try:
    from PIL import Image
    PILLOW_AVAILABLE = True
except ImportError:
    PILLOW_AVAILABLE = False

[docs] class MetadataEditor: """ A comprehensive metadata editor for audio files using mutagen CLI tools. Supports reading and writing metadata for all major audio formats. """
[docs] def __init__(self): """ Initialize MetadataEditor for working with audio file metadata. """ self.supported_formats = { '.mp3': 'MP3', '.flac': 'FLAC', '.ogg': 'OGG Vorbis', '.oga': 'OGG Vorbis', '.opus': 'OPUS', '.m4a': 'MP4/M4A', '.mp4': 'MP4', '.aac': 'AAC', '.wv': 'WavPack', '.ape': 'Monkey\'s Audio', '.mpc': 'Musepack', '.wav': 'WAV (if ID3 tags present)' } self.processed_count = 0 self.error_count = 0 # Check for mutagen library self._check_mutagen_library()
def _check_mutagen_library(self): """ Check if mutagen library is available and log warnings if needed. Logs availability of mutagen library and PIL for image processing. """ if not MUTAGEN_AVAILABLE: logger.error("Mutagen library not found. Please install with: pip install mutagen") logger.error("This module requires mutagen for direct metadata manipulation.") if not PILLOW_AVAILABLE: logger.warning("Pillow not found. Image format detection will be limited.") logger.warning("Install with: pip install Pillow") if MUTAGEN_AVAILABLE: logger.debug("Mutagen library loaded successfully - using direct metadata access")
[docs] def is_supported_format(self, filepath: str) -> bool: """ Check if the file format is supported. Args: filepath (str): Path to the audio file to check Returns: bool: True if the file format is supported, False otherwise """ ext = Path(filepath).suffix.lower() return ext in self.supported_formats
def _detect_format(self, filepath: str) -> str: """ Detect the audio format from file extension. Args: filepath (str): Path to the audio file Returns: str: Human-readable format name (e.g., 'MP3', 'FLAC', 'Unknown') """ ext = Path(filepath).suffix.lower() return self.supported_formats.get(ext, 'Unknown')
[docs] def get_metadata(self, filepath: str) -> Dict[str, Any]: """ Get all metadata from an audio file using mutagen library directly. Args: filepath (str): Path to the audio file to extract metadata from Returns: Dict[str, Any]: Dictionary with standardized tag names and metadata values, or empty dict if extraction fails """ if not MUTAGEN_AVAILABLE: logger.error("Mutagen library not available") return {} if not os.path.exists(filepath): logger.error(f"File not found: {filepath}") return {} try: # Load the audio file using mutagen audio_file = MutagenFile(filepath) if audio_file is None: logger.error(f"Could not load audio file: {os.path.basename(filepath)}") return {} # Extract metadata using mutagen's direct access metadata = self._extract_mutagen_metadata(audio_file) metadata['filepath'] = filepath metadata['format'] = self._detect_format(filepath) metadata['has_album_art'] = self._has_album_art_mutagen(audio_file) return metadata except Exception as e: logger.error(f"Error reading metadata from {os.path.basename(filepath)}: {str(e)}") return {}
def _extract_mutagen_metadata(self, audio_file) -> Dict[str, Any]: """ Extract metadata from a mutagen audio file object. Args: audio_file: Mutagen audio file object Returns: Dict[str, Any]: Dictionary containing parsed metadata with standardized keys """ metadata = {} if audio_file is None: return metadata # Handle different file formats # Check for ID3 tags first (can be in MP3, WAV, or other formats) if hasattr(audio_file, 'tags') and audio_file.tags and isinstance(audio_file.tags, ID3): metadata = self._extract_id3_metadata(audio_file) elif isinstance(audio_file, FLAC): metadata = self._extract_vorbis_metadata(audio_file) elif isinstance(audio_file, (OggVorbis, OggOpus)): metadata = self._extract_vorbis_metadata(audio_file) elif isinstance(audio_file, MP4): metadata = self._extract_mp4_metadata(audio_file) else: # Generic extraction for other formats metadata = self._extract_generic_metadata(audio_file) # Add audio properties if available if hasattr(audio_file, 'info') and audio_file.info: info = audio_file.info if hasattr(info, 'length'): metadata['length'] = getattr(info, 'length', 0) if hasattr(info, 'bitrate'): metadata['bitrate'] = getattr(info, 'bitrate', 0) if hasattr(info, 'sample_rate'): metadata['sample_rate'] = getattr(info, 'sample_rate', 0) if hasattr(info, 'bits_per_sample'): metadata['bit_depth'] = getattr(info, 'bits_per_sample', 0) return metadata def _extract_id3_metadata(self, audio_file) -> Dict[str, Any]: """Extract metadata from ID3 tags (MP3). Args: audio_file: Mutagen audio file object with ID3 tags. Returns: Dict[str, Any]: Dictionary containing extracted metadata. """ metadata = {} if not hasattr(audio_file, 'tags') or audio_file.tags is None: return metadata tags = audio_file.tags # ID3 tag mappings tag_map = { 'TIT2': 'title', 'TPE1': 'artist', 'TALB': 'album', 'TPE2': 'albumartist', 'TDRC': 'date', 'TYER': 'year', 'TCON': 'genre', 'TRCK': 'track', 'TPOS': 'disc', 'COMM::eng': 'comment', 'TCOM': 'composer', 'TPE3': 'performer', 'TIT1': 'grouping', 'USLT::eng': 'lyrics', 'TORY': 'originalyear', 'TCMP': 'compilation' } for tag_id, std_key in tag_map.items(): if tag_id in tags: value = str(tags[tag_id].text[0]) if tags[tag_id].text else '' if value: metadata[std_key] = value return metadata def _extract_vorbis_metadata(self, audio_file) -> Dict[str, Any]: """Extract metadata from Vorbis comments (FLAC, OGG, OPUS). Args: audio_file: Mutagen audio file object with Vorbis comments. Returns: Dict[str, Any]: Dictionary containing extracted metadata. """ metadata = {} if not hasattr(audio_file, 'tags') or audio_file.tags is None: return metadata tags = audio_file.tags # Vorbis comment mappings tag_map = { 'TITLE': 'title', 'ARTIST': 'artist', 'ALBUM': 'album', 'ALBUMARTIST': 'albumartist', 'ALBUM ARTIST': 'albumartist', # Alternative format with space 'DATE': 'date', 'YEAR': 'year', 'GENRE': 'genre', 'TRACKNUMBER': 'track', 'DISCNUMBER': 'disc', 'COMMENT': 'comment', 'COMPOSER': 'composer', 'PERFORMER': 'performer', 'GROUPING': 'grouping', 'LYRICS': 'lyrics', 'ORIGINALDATE': 'originaldate', 'ORIGINALYEAR': 'originalyear', 'COMPILATION': 'compilation' } for tag_name, std_key in tag_map.items(): if tag_name in tags: value = tags[tag_name][0] if tags[tag_name] else '' if value: metadata[std_key] = value return metadata def _extract_mp4_metadata(self, audio_file) -> Dict[str, Any]: """Extract metadata from MP4/M4A tags. Args: audio_file: Mutagen audio file object with MP4 tags. Returns: Dict[str, Any]: Dictionary containing extracted metadata. """ metadata = {} if not hasattr(audio_file, 'tags') or audio_file.tags is None: return metadata tags = audio_file.tags # MP4 tag mappings tag_map = { '\xa9nam': 'title', '\xa9ART': 'artist', '\xa9alb': 'album', 'aART': 'albumartist', '\xa9day': 'date', '\xa9gen': 'genre', 'trkn': 'track', 'disk': 'disc', '\xa9cmt': 'comment', '\xa9wrt': 'composer', '\xa9grp': 'grouping', '\xa9lyr': 'lyrics', 'cpil': 'compilation' } for tag_name, std_key in tag_map.items(): if tag_name in tags: value = tags[tag_name] if isinstance(value, list) and value: if tag_name in ['trkn', 'disk'] and isinstance(value[0], tuple): metadata[std_key] = str(value[0][0]) else: metadata[std_key] = str(value[0]) elif value: metadata[std_key] = str(value) return metadata def _extract_generic_metadata(self, audio_file) -> Dict[str, Any]: """Extract metadata from generic formats. Args: audio_file: Mutagen audio file object. Returns: Dict[str, Any]: Dictionary containing extracted metadata. """ metadata = {} if not hasattr(audio_file, 'tags') or audio_file.tags is None: return metadata # Try to extract common fields tags = audio_file.tags # Common field names to try common_fields = ['title', 'artist', 'album', 'albumartist', 'date', 'year', 'genre', 'track', 'disc', 'comment', 'composer'] for field in common_fields: for case_variant in [field.upper(), field.lower(), field.title()]: if case_variant in tags: value = tags[case_variant] if isinstance(value, list) and value: metadata[field] = str(value[0]) elif value: metadata[field] = str(value) break return metadata def _has_album_art_mutagen(self, audio_file) -> bool: """ Check if the audio file has album art using mutagen directly. Args: audio_file: Mutagen audio file object Returns: bool: True if file contains embedded album art, False otherwise """ if audio_file is None or not hasattr(audio_file, 'tags') or audio_file.tags is None: return False try: # Check for album art based on file type if isinstance(audio_file, MP3): # Check for APIC frames in ID3 tags return any(key.startswith('APIC:') for key in audio_file.tags.keys()) elif isinstance(audio_file, FLAC): # Check for pictures in FLAC return len(audio_file.pictures) > 0 elif isinstance(audio_file, (OggVorbis, OggOpus)): # Check for METADATA_BLOCK_PICTURE in Vorbis comments return 'METADATA_BLOCK_PICTURE' in audio_file.tags elif isinstance(audio_file, MP4): # Check for cover art in MP4 return 'covr' in audio_file.tags else: # Generic check for picture-related tags tags = audio_file.tags picture_indicators = ['APIC', 'covr', 'METADATA_BLOCK_PICTURE', 'PICTURE'] return any(any(indicator in str(key) for indicator in picture_indicators) for key in tags.keys()) except Exception: return False
[docs] def set_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool: """ Set metadata for an audio file using mutagen library directly. Args: filepath (str): Path to the audio file to modify metadata (Dict[str, Any]): Dictionary containing metadata to set Returns: bool: True if metadata was successfully set, False otherwise """ if not MUTAGEN_AVAILABLE: logger.error("Mutagen library not available") return False if not os.path.exists(filepath): logger.error(f"File not found: {filepath}") return False if not self.is_supported_format(filepath): logger.error(f"Unsupported format: {os.path.basename(filepath)}") return False try: # Load the audio file using mutagen audio_file = MutagenFile(filepath) if audio_file is None: logger.error(f"Could not load audio file: {os.path.basename(filepath)}") return False # Set metadata based on file type success = self._set_metadata_mutagen(audio_file, metadata, filepath) if success: logger.info(f"Successfully updated metadata for {os.path.basename(filepath)}") self.processed_count += 1 return True else: self.error_count += 1 return False except Exception as e: logger.error(f"Error setting metadata for {os.path.basename(filepath)}: {str(e)}") self.error_count += 1 return False
def _set_metadata_mutagen(self, audio_file, metadata: Dict[str, Any], filepath: str) -> bool: """ Set metadata using mutagen library directly. Args: audio_file: Mutagen audio file object metadata (Dict[str, Any]): Dictionary containing metadata to set filepath (str): Path to the audio file for saving Returns: bool: True if metadata was successfully set, False otherwise """ try: # Handle different file formats if isinstance(audio_file, MP3): return self._set_id3_metadata(audio_file, metadata, filepath) elif isinstance(audio_file, FLAC): return self._set_vorbis_metadata(audio_file, metadata, filepath) elif isinstance(audio_file, (OggVorbis, OggOpus)): return self._set_vorbis_metadata(audio_file, metadata, filepath) elif isinstance(audio_file, MP4): return self._set_mp4_metadata(audio_file, metadata, filepath) else: return self._set_generic_metadata_mutagen(audio_file, metadata, filepath) except Exception as e: logger.error(f"Error setting metadata with mutagen: {str(e)}") return False def _set_id3_metadata(self, audio_file, metadata: Dict[str, Any], filepath: str) -> bool: """Set metadata for MP3 files using ID3 tags. Args: audio_file: Mutagen MP3 audio file object. metadata: Dictionary containing metadata to set. filepath: Path to the audio file. Returns: bool: True if metadata was successfully set, False otherwise. """ try: # Ensure ID3 tags exist if audio_file.tags is None: audio_file.add_tags() tags = audio_file.tags # ID3 tag mappings if 'title' in metadata and metadata['title']: tags['TIT2'] = TIT2(encoding=3, text=metadata['title']) if 'artist' in metadata and metadata['artist']: tags['TPE1'] = TPE1(encoding=3, text=metadata['artist']) if 'album' in metadata and metadata['album']: tags['TALB'] = TALB(encoding=3, text=metadata['album']) if 'albumartist' in metadata and metadata['albumartist']: tags['TPE2'] = TPE2(encoding=3, text=metadata['albumartist']) if 'date' in metadata and metadata['date']: tags['TDRC'] = TDRC(encoding=3, text=metadata['date']) elif 'year' in metadata and metadata['year']: tags['TDRC'] = TDRC(encoding=3, text=metadata['year']) if 'genre' in metadata and metadata['genre']: tags['TCON'] = TCON(encoding=3, text=metadata['genre']) if 'track' in metadata and metadata['track']: tags['TRCK'] = TRCK(encoding=3, text=str(metadata['track'])) if 'disc' in metadata and metadata['disc']: tags['TPOS'] = TPOS(encoding=3, text=str(metadata['disc'])) if 'comment' in metadata and metadata['comment']: tags['COMM::eng'] = COMM(encoding=3, lang='eng', desc='', text=metadata['comment']) audio_file.save() return True except Exception as e: logger.error(f"Error setting ID3 metadata: {str(e)}") return False def _set_vorbis_metadata(self, audio_file, metadata: Dict[str, Any], filepath: str) -> bool: """Set metadata for Vorbis comment based files (FLAC, OGG, OPUS). Args: audio_file: Mutagen audio file object with Vorbis comments. metadata: Dictionary containing metadata to set. filepath: Path to the audio file. Returns: bool: True if metadata was successfully set, False otherwise. """ try: # Ensure tags exist if audio_file.tags is None: audio_file.add_tags() tags = audio_file.tags # Vorbis comment mappings if 'title' in metadata and metadata['title']: tags['TITLE'] = [metadata['title']] if 'artist' in metadata and metadata['artist']: tags['ARTIST'] = [metadata['artist']] if 'album' in metadata and metadata['album']: tags['ALBUM'] = [metadata['album']] if 'albumartist' in metadata and metadata['albumartist']: tags['ALBUMARTIST'] = [metadata['albumartist']] if 'date' in metadata and metadata['date']: tags['DATE'] = [metadata['date']] elif 'year' in metadata and metadata['year']: tags['DATE'] = [metadata['year']] if 'genre' in metadata and metadata['genre']: tags['GENRE'] = [metadata['genre']] if 'track' in metadata and metadata['track']: tags['TRACKNUMBER'] = [str(metadata['track'])] if 'disc' in metadata and metadata['disc']: tags['DISCNUMBER'] = [str(metadata['disc'])] if 'comment' in metadata and metadata['comment']: tags['COMMENT'] = [metadata['comment']] audio_file.save() return True except Exception as e: logger.error(f"Error setting Vorbis metadata: {str(e)}") return False def _set_mp4_metadata(self, audio_file, metadata: Dict[str, Any], filepath: str) -> bool: """Set metadata for MP4/M4A files. Args: audio_file: Mutagen MP4 audio file object. metadata: Dictionary containing metadata to set. filepath: Path to the audio file. Returns: bool: True if metadata was successfully set, False otherwise. """ try: # Ensure tags exist if audio_file.tags is None: audio_file.add_tags() tags = audio_file.tags # MP4 tag mappings if 'title' in metadata and metadata['title']: tags['\xa9nam'] = [metadata['title']] if 'artist' in metadata and metadata['artist']: tags['\xa9ART'] = [metadata['artist']] if 'album' in metadata and metadata['album']: tags['\xa9alb'] = [metadata['album']] if 'albumartist' in metadata and metadata['albumartist']: tags['aART'] = [metadata['albumartist']] if 'date' in metadata and metadata['date']: tags['\xa9day'] = [metadata['date']] elif 'year' in metadata and metadata['year']: tags['\xa9day'] = [metadata['year']] if 'genre' in metadata and metadata['genre']: tags['\xa9gen'] = [metadata['genre']] if 'track' in metadata and metadata['track']: try: track_num = int(metadata['track']) tags['trkn'] = [(track_num, 0)] except ValueError: pass if 'disc' in metadata and metadata['disc']: try: disc_num = int(metadata['disc']) tags['disk'] = [(disc_num, 0)] except ValueError: pass if 'comment' in metadata and metadata['comment']: tags['\xa9cmt'] = [metadata['comment']] audio_file.save() return True except Exception as e: logger.error(f"Error setting MP4 metadata: {str(e)}") return False def _set_generic_metadata_mutagen(self, audio_file, metadata: Dict[str, Any], filepath: str) -> bool: """Set metadata for generic formats. Args: audio_file: Mutagen audio file object. metadata: Dictionary containing metadata to set. filepath: Path to the audio file. Returns: bool: True if metadata was successfully set, False otherwise. """ try: # Ensure tags exist if audio_file.tags is None: audio_file.add_tags() tags = audio_file.tags # Try to set common fields for key, value in metadata.items(): if value: # Try uppercase version (common for many formats) tags[key.upper()] = [str(value)] audio_file.save() return True except Exception as e: logger.error(f"Error setting generic metadata: {str(e)}") return False
[docs] def set_album_art(self, filepath: str, image_path: str) -> bool: """ Set album art for an audio file using mutagen library directly. Args: filepath (str): Path to the audio file to modify image_path (str): Path to the image file to embed as album art Returns: bool: True if album art was successfully set, False otherwise """ if not MUTAGEN_AVAILABLE: logger.error("Mutagen library not available") return False if not os.path.exists(image_path): logger.error(f"Image file not found: {image_path}") return False if not os.path.exists(filepath): logger.error(f"Audio file not found: {filepath}") return False try: # Load the audio file using mutagen audio_file = MutagenFile(filepath) if audio_file is None: logger.error(f"Could not load audio file: {os.path.basename(filepath)}") return False # Set album art based on file type success = self._set_album_art_mutagen(audio_file, image_path, filepath) if success: logger.info(f"Successfully set album art for {os.path.basename(filepath)} using mutagen") return True else: # Fallback to FFmpeg if mutagen fails logger.info(f"Mutagen album art failed, falling back to FFmpeg for {os.path.basename(filepath)}") return self._set_album_art_ffmpeg(filepath, image_path) except Exception as e: logger.error(f"Error setting album art for {os.path.basename(filepath)}: {str(e)}") # Fallback to FFmpeg return self._set_album_art_ffmpeg(filepath, image_path)
def _set_album_art_mutagen(self, audio_file, image_path: str, filepath: str) -> bool: """ Set album art using mutagen library directly. Args: audio_file: Mutagen audio file object image_path (str): Path to the image file to embed as album art filepath (str): Path to the audio file for saving Returns: bool: True if album art was successfully set, False otherwise """ try: # Read the image file with open(image_path, 'rb') as img_file: image_data = img_file.read() # Detect image format image_format = self._detect_image_format(image_data, image_path) if not image_format: logger.error(f"Unsupported image format: {os.path.basename(image_path)}") return False # Set album art based on audio file type if isinstance(audio_file, MP3): return self._set_mp3_album_art(audio_file, image_data, image_format, filepath) elif isinstance(audio_file, FLAC): return self._set_flac_album_art(audio_file, image_data, image_format, filepath) elif isinstance(audio_file, (OggVorbis, OggOpus)): return self._set_ogg_album_art(audio_file, image_data, image_format, filepath) elif isinstance(audio_file, MP4): return self._set_mp4_album_art(audio_file, image_data, image_format, filepath) else: logger.warning(f"Album art embedding not supported for this format via mutagen: {type(audio_file)}") return False except Exception as e: logger.error(f"Error setting album art with mutagen: {str(e)}") return False def _detect_image_format(self, image_data: bytes, image_path: str) -> Optional[str]: """Detect image format from data or filename. Args: image_data: Binary data of the image. image_path: Path to the image file. Returns: Optional[str]: MIME type of the image format, or None if unsupported. """ # Try to detect from data first if image_data.startswith(b'\xff\xd8\xff'): return 'image/jpeg' elif image_data.startswith(b'\x89PNG\r\n\x1a\n'): return 'image/png' elif image_data.startswith(b'GIF87a') or image_data.startswith(b'GIF89a'): return 'image/gif' elif image_data.startswith(b'WEBP', 8): return 'image/webp' # Fallback to file extension ext = Path(image_path).suffix.lower() format_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp' } return format_map.get(ext) def _set_mp3_album_art(self, audio_file, image_data: bytes, image_format: str, filepath: str) -> bool: """Set album art for MP3 files using ID3 APIC frame. Args: audio_file: Mutagen MP3 audio file object. image_data: Binary data of the image. image_format: MIME type of the image. filepath: Path to the audio file. Returns: bool: True if album art was successfully set, False otherwise. """ try: # Ensure ID3 tags exist if audio_file.tags is None: audio_file.add_tags() # Remove existing album art audio_file.tags.delall('APIC') # Add new album art audio_file.tags.add( APIC( encoding=3, # UTF-8 mime=image_format, type=3, # Cover (front) desc='Cover', data=image_data ) ) audio_file.save() return True except Exception as e: logger.error(f"Error setting MP3 album art: {str(e)}") return False def _set_flac_album_art(self, audio_file, image_data: bytes, image_format: str, filepath: str) -> bool: """Set album art for FLAC files using Picture blocks. Args: audio_file: Mutagen FLAC audio file object. image_data: Binary data of the image. image_format: MIME type of the image. filepath: Path to the audio file. Returns: bool: True if album art was successfully set, False otherwise. """ try: # Clear existing pictures audio_file.clear_pictures() # Create new picture picture = Picture() picture.type = 3 # Cover (front) picture.mime = image_format picture.desc = 'Cover' picture.data = image_data # Add picture dimensions if possible if PILLOW_AVAILABLE: try: from PIL import Image img = Image.open(io.BytesIO(image_data)) picture.width, picture.height = img.size picture.depth = img.mode.count('A') and 32 or 24 # 32 for RGBA, 24 for RGB except Exception: pass audio_file.add_picture(picture) audio_file.save() return True except Exception as e: logger.error(f"Error setting FLAC album art: {str(e)}") return False def _set_ogg_album_art(self, audio_file, image_data: bytes, image_format: str, filepath: str) -> bool: """Set album art for OGG files using METADATA_BLOCK_PICTURE. Args: audio_file: Mutagen OGG audio file object. image_data: Binary data of the image. image_format: MIME type of the image. filepath: Path to the audio file. Returns: bool: True if album art was successfully set, False otherwise. """ try: # Ensure tags exist if audio_file.tags is None: audio_file.add_tags() # Create FLAC picture block for embedding in Vorbis comment picture = Picture() picture.type = 3 # Cover (front) picture.mime = image_format picture.desc = 'Cover' picture.data = image_data # Add picture dimensions if possible if PILLOW_AVAILABLE: try: from PIL import Image img = Image.open(io.BytesIO(image_data)) picture.width, picture.height = img.size picture.depth = img.mode.count('A') and 32 or 24 except Exception: pass # Encode picture as base64 for METADATA_BLOCK_PICTURE picture_data = picture.write() encoded_data = base64.b64encode(picture_data).decode('ascii') # Remove existing album art if 'METADATA_BLOCK_PICTURE' in audio_file.tags: del audio_file.tags['METADATA_BLOCK_PICTURE'] # Add new album art audio_file.tags['METADATA_BLOCK_PICTURE'] = [encoded_data] audio_file.save() return True except Exception as e: logger.error(f"Error setting OGG album art: {str(e)}") return False def _set_mp4_album_art(self, audio_file, image_data: bytes, image_format: str, filepath: str) -> bool: """Set album art for MP4/M4A files. Args: audio_file: Mutagen MP4 audio file object. image_data: Binary data of the image. image_format: MIME type of the image. filepath: Path to the audio file. Returns: bool: True if album art was successfully set, False otherwise. """ try: # Ensure tags exist if audio_file.tags is None: audio_file.add_tags() # Determine format code for MP4 if image_format == 'image/jpeg': format_code = MP4.MP4Cover.FORMAT_JPEG elif image_format == 'image/png': format_code = MP4.MP4Cover.FORMAT_PNG else: # Default to JPEG for other formats format_code = MP4.MP4Cover.FORMAT_JPEG # Create cover object cover = MP4.MP4Cover(image_data, format_code) # Set album art audio_file.tags['covr'] = [cover] audio_file.save() return True except Exception as e: logger.error(f"Error setting MP4 album art: {str(e)}") return False def _set_album_art_ffmpeg(self, filepath: str, image_path: str) -> bool: """ Set album art using FFmpeg. Args: filepath (str): Path to the audio file to modify image_path (str): Path to the image file to embed as album art Returns: bool: True if album art was successfully set, False otherwise """ try: # Get file extension to determine format file_ext = os.path.splitext(filepath)[1].lower() # Create temporary output file with proper extension temp_file = filepath + '.tmp' + file_ext cmd = [ 'ffmpeg', '-y', '-i', str(filepath), '-i', str(image_path), '-map', '0', '-map', '1', '-c', 'copy', '-disposition:v:0', 'attached_pic' ] # Add metadata for the attached picture if file_ext == '.mp3': cmd.extend(['-id3v2_version', '3']) cmd.extend([ '-metadata:s:v', 'title=Cover (front)', '-metadata:s:v', 'comment=Cover (front)' ]) # Add format specification for files that need it if file_ext in ['.flac', '.ogg', '.opus']: if file_ext == '.flac': cmd.extend(['-f', 'flac']) elif file_ext == '.ogg': cmd.extend(['-f', 'ogg']) elif file_ext == '.opus': cmd.extend(['-f', 'opus']) cmd.append(temp_file) result = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=60 ) if result.returncode == 0 and os.path.exists(temp_file): # Replace original file with the new one os.replace(temp_file, filepath) logger.info(f"Successfully set album art for {os.path.basename(filepath)} using FFmpeg") return True else: logger.error(f"FFmpeg album art error: {result.stderr}") # Clean up temp file if it exists if os.path.exists(temp_file): os.unlink(temp_file) return False except Exception as e: logger.error(f"Error setting album art with FFmpeg: {str(e)}") return False
[docs] def remove_album_art(self, filepath: str) -> bool: """ Remove album art from an audio file using mutagen library directly. Args: filepath (str): Path to the audio file to modify Returns: bool: True if album art was successfully removed, False otherwise """ if not MUTAGEN_AVAILABLE: logger.error("Mutagen library not available") return False if not os.path.exists(filepath): logger.error(f"File not found: {filepath}") return False try: # Load the audio file using mutagen audio_file = MutagenFile(filepath) if audio_file is None: logger.error(f"Could not load audio file: {os.path.basename(filepath)}") return False # Remove album art based on file type success = self._remove_album_art_mutagen(audio_file, filepath) if success: logger.info(f"Successfully removed album art from {os.path.basename(filepath)} using mutagen") return True else: # Fallback to FFmpeg if mutagen fails logger.info(f"Mutagen album art removal failed, falling back to FFmpeg for {os.path.basename(filepath)}") return self._remove_album_art_ffmpeg(filepath) except Exception as e: logger.error(f"Error removing album art from {os.path.basename(filepath)}: {str(e)}") # Fallback to FFmpeg return self._remove_album_art_ffmpeg(filepath)
def _remove_album_art_mutagen(self, audio_file, filepath: str) -> bool: """ Remove album art using mutagen library directly. Args: audio_file: Mutagen audio file object filepath (str): Path to the audio file for saving Returns: bool: True if album art was successfully removed, False otherwise """ try: # Remove album art based on audio file type if isinstance(audio_file, MP3): if audio_file.tags is not None: audio_file.tags.delall('APIC') audio_file.save() return True elif isinstance(audio_file, FLAC): audio_file.clear_pictures() audio_file.save() return True elif isinstance(audio_file, (OggVorbis, OggOpus)): if audio_file.tags is not None and 'METADATA_BLOCK_PICTURE' in audio_file.tags: del audio_file.tags['METADATA_BLOCK_PICTURE'] audio_file.save() return True elif isinstance(audio_file, MP4): if audio_file.tags is not None and 'covr' in audio_file.tags: del audio_file.tags['covr'] audio_file.save() return True else: logger.warning(f"Album art removal not supported for this format via mutagen: {type(audio_file)}") return False return True except Exception as e: logger.error(f"Error removing album art with mutagen: {str(e)}") return False def _remove_album_art_ffmpeg(self, filepath: str) -> bool: """ Remove album art using FFmpeg by copying audio streams without video. Args: filepath (str): Path to the audio file to modify Returns: bool: True if album art was successfully removed, False otherwise """ try: # Get file extension to determine format file_ext = os.path.splitext(filepath)[1].lower() # Create temporary output file with proper extension temp_file = filepath + '.tmp' + file_ext cmd = [ 'ffmpeg', '-y', '-i', str(filepath), '-map', '0:a', '-c', 'copy' ] # Add format specification for files that need it if file_ext in ['.flac', '.ogg', '.opus']: if file_ext == '.flac': cmd.extend(['-f', 'flac']) elif file_ext == '.ogg': cmd.extend(['-f', 'ogg']) elif file_ext == '.opus': cmd.extend(['-f', 'opus']) cmd.append(temp_file) result = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=60 ) if result.returncode == 0 and os.path.exists(temp_file): # Replace original file with the new one os.replace(temp_file, filepath) logger.info(f"Successfully removed album art from {os.path.basename(filepath)} using FFmpeg") return True else: logger.error(f"FFmpeg album art removal error: {result.stderr}") # Clean up temp file if it exists if os.path.exists(temp_file): os.unlink(temp_file) return False except Exception as e: logger.error(f"Error removing album art with FFmpeg: {str(e)}") return False
[docs] def batch_edit_metadata(self, file_paths: List[str], metadata: Dict[str, Any]) -> Dict[str, int]: """ Edit metadata for multiple files. Args: file_paths (List[str]): List of file paths to modify metadata (Dict[str, Any]): Dictionary containing metadata to set Returns: Dict[str, int]: Statistics about the operation with keys: - processed: Number of successfully processed files - errors: Number of files that failed processing """ self.processed_count = 0 self.error_count = 0 for filepath in file_paths: if not os.path.exists(filepath): logger.warning(f"File not found: {filepath}") self.error_count += 1 continue if not self.is_supported_format(filepath): logger.warning(f"Unsupported format: {os.path.basename(filepath)}") self.error_count += 1 continue self.set_metadata(filepath, metadata) return { 'processed': self.processed_count, 'errors': self.error_count, 'total': len(file_paths) }
[docs] def display_metadata(self, filepath: str): """ Display metadata for a file in a readable format. Args: filepath (str): Path to the audio file to display metadata for """ metadata = self.get_metadata(filepath) if not metadata: logger.error(f"Could not read metadata from {os.path.basename(filepath)}") return # Get audio properties including duration audio_info = self._get_audio_info(filepath) print(f"\nMetadata for: {os.path.basename(filepath)}") print("=" * 50) print(f"Format: {metadata.get('format', 'Unknown')}") print(f"Title: {metadata.get('title', '')}") print(f"Artist: {metadata.get('artist', '')}") print(f"Album: {metadata.get('album', '')}") print(f"Album Artist: {metadata.get('albumartist', '')}") print(f"Date/Year: {metadata.get('date', metadata.get('year', ''))}") print(f"Genre: {metadata.get('genre', '')}") print(f"Track: {metadata.get('track', '')}") print(f"Disc: {metadata.get('disc', '')}") print(f"Comment: {metadata.get('comment', '')}") print(f"Album Art: {'Yes' if metadata.get('has_album_art') else 'No'}") # Display audio properties if audio_info.get('duration', 0) > 0: duration = audio_info['duration'] minutes = int(duration // 60) seconds = int(duration % 60) print(f"Duration: {minutes:02d}:{seconds:02d} ({duration:.1f} seconds)") else: print("Duration: Unknown") if audio_info.get('bitrate', 0) > 0: print(f"Bitrate: {audio_info['bitrate']} kbps") if audio_info.get('sample_rate', 0) > 0: print(f"Sample Rate: {audio_info['sample_rate']} Hz") if audio_info.get('bit_depth', 0) > 0: print(f"Bit Depth: {audio_info['bit_depth']} bits")
[docs] def extract_metadata_for_database(self, filepath: str) -> Dict[str, Any]: """ Extract metadata in the format expected by database.py. Args: filepath (str): Path to the audio file to extract metadata from Returns: Dict[str, Any]: Dictionary with all metadata fields that database.py expects, or None if metadata extraction fails """ metadata = self.get_metadata(filepath) if not metadata: return None # Get audio properties using FFprobe audio_info = self._get_audio_info(filepath) # Convert to database format db_metadata = { 'title': metadata.get('title', ''), 'album': metadata.get('album', ''), 'artist': metadata.get('artist', ''), 'albumartist': metadata.get('albumartist', ''), 'track': self._parse_number(metadata.get('track', '0')), 'disc': self._parse_number(metadata.get('disc', '0')), 'year': self._parse_number(metadata.get('date', metadata.get('year', '0'))), 'originalyear': self._parse_number(metadata.get('originaldate', metadata.get('originalyear', '0'))), 'genre': metadata.get('genre', ''), 'composer': metadata.get('composer', ''), 'performer': metadata.get('performer', ''), 'grouping': metadata.get('grouping', ''), 'comment': metadata.get('comment', ''), 'lyrics': metadata.get('lyrics', ''), 'length': audio_info.get('duration', 0), 'bitrate': audio_info.get('bitrate', 0), 'samplerate': audio_info.get('sample_rate', 0), 'bitdepth': audio_info.get('bit_depth', 0), 'compilation': 1 if metadata.get('compilation', '').lower() in ['1', 'true', 'yes'] else 0, 'art_embedded': 1 if metadata.get('has_album_art', False) else 0 } return db_metadata
[docs] def extract_metadata_for_playlist(self, filepath: str) -> Dict[str, Any]: """ Extract metadata in the format expected by playlist.py. Args: filepath (str): Path to the audio file to extract metadata from Returns: Dict[str, Any]: Dictionary with metadata fields that playlist.py expects, or None if metadata extraction fails """ metadata = self.get_metadata(filepath) if not metadata: return None # Get audio properties audio_info = self._get_audio_info(filepath) # Convert to playlist format playlist_metadata = { 'url': filepath, 'title': metadata.get('title', ''), 'artist': metadata.get('artist', ''), 'album': metadata.get('album', ''), 'albumartist': metadata.get('albumartist', ''), 'length': audio_info.get('duration', 0), 'track': self._parse_number(metadata.get('track', '0')), 'disc': self._parse_number(metadata.get('disc', '0')), 'year': self._parse_number(metadata.get('date', metadata.get('year', '0'))), 'genre': metadata.get('genre', '') } return playlist_metadata
def _get_audio_info(self, filepath: str) -> Dict[str, Any]: """ Get audio information using FFprobe. Args: filepath (str): Path to the audio file to analyze Returns: Dict[str, Any]: Dictionary containing audio properties like duration, bitrate, sample rate, etc., or empty dict if analysis fails """ try: info = {} # Get duration duration_cmd = [ 'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', str(filepath) ] duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True, timeout=30) if duration_result.stdout.strip(): try: info['duration'] = float(duration_result.stdout.strip()) except ValueError: pass # Get bit rate bitrate_cmd = [ 'ffprobe', '-v', 'quiet', '-show_entries', 'format=bit_rate', '-of', 'csv=p=0', str(filepath) ] bitrate_result = subprocess.run(bitrate_cmd, capture_output=True, text=True, check=True, timeout=30) if bitrate_result.stdout.strip(): try: info['bitrate'] = int(float(bitrate_result.stdout.strip())) // 1000 # Convert to kbps except ValueError: pass # Get sample rate samplerate_cmd = [ 'ffprobe', '-v', 'quiet', '-show_entries', 'stream=sample_rate', '-of', 'csv=p=0', str(filepath) ] samplerate_result = subprocess.run(samplerate_cmd, capture_output=True, text=True, check=True, timeout=30) if samplerate_result.stdout.strip(): try: info['sample_rate'] = int(samplerate_result.stdout.strip()) except ValueError: pass return info except Exception: return {} def _parse_number(self, value: str) -> int: """ Parse a string value to extract a numeric value for track/disc numbers. Args: value (str): The string value to parse (e.g., "3/10" or "3") Returns: int: The parsed numeric value, or 0 if parsing fails """ if not value: return 0 # Handle track numbers like "1/10" or "1" if '/' in str(value): value = str(value).split('/')[0] try: return int(float(str(value))) except (ValueError, TypeError): return 0
[docs] def embed_opus_album_art(self, opus_filepath: str, image_path: str) -> bool: """ Embed album art into OPUS files. Args: opus_filepath (str): Path to the OPUS audio file to modify image_path (str): Path to the image file to embed as album art Returns: bool: True if album art was successfully embedded, False otherwise """ if not os.path.exists(image_path): logger.error(f"Image file not found: {image_path}") return False if not os.path.exists(opus_filepath): logger.error(f"OPUS file not found: {opus_filepath}") return False try: return self.set_album_art(opus_filepath, image_path) except Exception as e: logger.error(f"Error embedding OPUS album art: {str(e)}") return False
[docs] def main(): """ Main function for command-line usage. Returns: int: Exit code (0 for success, 1 for failure) """ parser = argparse.ArgumentParser( description="Modify audio file metadata using mutagen", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Display metadata python metadata.py --show song.mp3 # Set title and artist python metadata.py --set-title "New Title" --set-artist "New Artist" song.mp3 # Set album art python metadata.py --set-album-art cover.jpg song.mp3 # Remove album art python metadata.py --remove-album-art song.mp3 # Batch edit multiple files python metadata.py --set-album "Album Name" *.mp3 """ ) parser.add_argument('files', nargs='*', help='Audio files to process') parser.add_argument('--show', action='store_true', help='Display current metadata') parser.add_argument('--duration', action='store_true', help='Show only duration in seconds') parser.add_argument('--set-title', help='Set title tag') parser.add_argument('--set-artist', help='Set artist tag') parser.add_argument('--set-album', help='Set album tag') parser.add_argument('--set-albumartist', help='Set album artist tag') parser.add_argument('--set-date', help='Set date tag') parser.add_argument('--set-year', help='Set year tag') parser.add_argument('--set-genre', help='Set genre tag') parser.add_argument('--set-track', help='Set track number') parser.add_argument('--set-disc', help='Set disc number') parser.add_argument('--set-comment', help='Set comment tag') parser.add_argument('--set-album-art', help='Set album art from image file') parser.add_argument('--remove-album-art', action='store_true', help='Remove album art') parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose logging') args = parser.parse_args() if args.verbose: logger.setLevel(logging.DEBUG) if not args.files: parser.print_help() return 1 editor = MetadataEditor() # Check if we're just displaying metadata if args.show: for filepath in args.files: if os.path.exists(filepath): editor.display_metadata(filepath) else: logger.error(f"File not found: {filepath}") return 0 # Check if we're just getting duration if args.duration: for filepath in args.files: if os.path.exists(filepath): audio_info = editor._get_audio_info(filepath) duration = audio_info.get('duration', 0) print(f"{duration}") else: logger.error(f"File not found: {filepath}") print("0") return 0 # Build metadata dictionary from arguments metadata = {} if args.set_title: metadata['title'] = args.set_title if args.set_artist: metadata['artist'] = args.set_artist if args.set_album: metadata['album'] = args.set_album if args.set_albumartist: metadata['albumartist'] = args.set_albumartist if args.set_date: metadata['date'] = args.set_date if args.set_year: metadata['year'] = args.set_year if args.set_genre: metadata['genre'] = args.set_genre if args.set_track: metadata['track'] = args.set_track if args.set_disc: metadata['disc'] = args.set_disc if args.set_comment: metadata['comment'] = args.set_comment # Process files success_count = 0 for filepath in args.files: if not os.path.exists(filepath): logger.error(f"File not found: {filepath}") continue if not editor.is_supported_format(filepath): logger.error(f"Unsupported format: {os.path.basename(filepath)}") continue # Set standard metadata if metadata: if editor.set_metadata(filepath, metadata): success_count += 1 # Handle album art operations if args.set_album_art: if editor.set_album_art(filepath, args.set_album_art): success_count += 1 if args.remove_album_art: if editor.remove_album_art(filepath): success_count += 1 logger.info(f"Successfully processed {success_count} out of {len(args.files)} files") return 0 if success_count > 0 else 1
if __name__ == "__main__": sys.exit(main()) # Convenience functions for easy importing by other modules
[docs] def extract_metadata(filepath: str) -> Dict[str, Any]: """ Convenience function for database.py compatibility. Extract metadata from an audio file in database format. Args: filepath (str): Path to the audio file to extract metadata from Returns: Dict[str, Any]: Dictionary with metadata fields in database format, or None if extraction fails """ editor = MetadataEditor() return editor.extract_metadata_for_database(filepath)
[docs] def extract_metadata_for_playlist(filepath: str) -> Dict[str, Any]: """ Convenience function for playlist.py compatibility. Extract metadata from an audio file in playlist format. Args: filepath (str): Path to the audio file to extract metadata from Returns: Dict[str, Any]: Dictionary with metadata fields in playlist format, or None if extraction fails """ editor = MetadataEditor() return editor.extract_metadata_for_playlist(filepath)
[docs] def set_metadata(filepath: str, metadata: Dict[str, Any]) -> bool: """ Convenience function to set metadata for a file. Args: filepath (str): Path to the audio file to modify metadata (Dict[str, Any]): Dictionary containing metadata to set Returns: bool: True if metadata was successfully set, False otherwise """ editor = MetadataEditor() return editor.set_metadata(filepath, metadata)
[docs] def set_album_art(filepath: str, image_path: str) -> bool: """ Convenience function to set album art for a file. Args: filepath (str): Path to the audio file to modify image_path (str): Path to the image file to embed as album art Returns: bool: True if album art was successfully set, False otherwise """ editor = MetadataEditor() return editor.set_album_art(filepath, image_path)
[docs] def embed_opus_album_art(opus_filepath: str, image_path: str) -> bool: """ Convenience function to embed album art in OPUS files. Args: opus_filepath (str): Path to the OPUS audio file to modify image_path (str): Path to the image file to embed as album art Returns: bool: True if album art was successfully embedded, False otherwise """ editor = MetadataEditor() return editor.embed_opus_album_art(opus_filepath, image_path)
[docs] def get_duration(filepath: str) -> float: """ Get the duration of an audio file in seconds. Args: filepath (str): Path to the audio file Returns: float: Duration in seconds, or 0.0 if unable to determine """ try: metadata = extract_metadata(filepath) if metadata and 'length' in metadata: return float(metadata['length']) except Exception: pass return 0.0
# Specific metadata extraction functions def _get_specific_tag(filepath: str, tag_key: str, alt_keys: list = None) -> str: """ Helper function to extract a specific tag from an audio file without loading all metadata. Args: filepath (str): Path to the audio file tag_key (str): Primary tag key to look for alt_keys (list): Alternative tag keys to check if primary is not found Returns: str: The tag value or empty string if not found """ if not MUTAGEN_AVAILABLE: return '' try: audio_file = MutagenFile(filepath) if audio_file is None: return '' # Try primary tag key first if hasattr(audio_file, 'tags') and audio_file.tags: # Check primary key if tag_key in audio_file.tags: value = audio_file.tags[tag_key] if isinstance(value, list) and value: return str(value[0]).strip() elif value: return str(value).strip() # Check alternative keys if provided if alt_keys: for alt_key in alt_keys: if alt_key in audio_file.tags: value = audio_file.tags[alt_key] if isinstance(value, list) and value: return str(value[0]).strip() elif value: return str(value).strip() return '' except Exception: return ''
[docs] def get_title(filepath: str) -> str: """ Get the title tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The title tag value or empty string if not found. """ return _get_specific_tag(filepath, 'TIT2', ['TITLE', 'Title'])
[docs] def get_artist(filepath: str) -> str: """ Get the artist tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The artist tag value or empty string if not found. """ return _get_specific_tag(filepath, 'TPE1', ['ARTIST', 'Artist'])
[docs] def get_album(filepath: str) -> str: """ Get the album tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The album tag value or empty string if not found. """ return _get_specific_tag(filepath, 'TALB', ['ALBUM', 'Album'])
[docs] def get_albumartist(filepath: str) -> str: """ Get the albumartist tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The album artist tag value or empty string if not found. """ return _get_specific_tag(filepath, 'TPE2', ['ALBUMARTIST', 'AlbumArtist', 'ALBUM ARTIST', 'Album Artist'])
[docs] def get_year(filepath: str) -> str: """ Get the year/date tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The year value or empty string if not found. """ # Try year first, then date year = _get_specific_tag(filepath, 'TDRC', ['DATE', 'YEAR', 'Year']) if year: # Extract just the year part if it's a full date year_match = re.match(r'(\d{4})', year) if year_match: return year_match.group(1) # Try alternative date tags date = _get_specific_tag(filepath, 'TYER', ['date']) if date: year_match = re.match(r'(\d{4})', date) if year_match: return year_match.group(1) return ''
[docs] def get_genre(filepath: str) -> str: """ Get the genre tag from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The genre tag value or empty string if not found. """ return _get_specific_tag(filepath, 'TCON', ['GENRE', 'Genre'])
[docs] def get_track(filepath: str) -> str: """ Get the track number from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The track number or empty string if not found. """ track = _get_specific_tag(filepath, 'TRCK', ['TRACKNUMBER', 'Track']) # Extract just the track number (remove "/total" if present) if track and '/' in track: return track.split('/')[0] return track
[docs] def get_disc(filepath: str) -> str: """ Get the disc number from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The disc number or empty string if not found. """ disc = _get_specific_tag(filepath, 'TPOS', ['DISCNUMBER', 'Disc']) # Extract just the disc number (remove "/total" if present) if disc and '/' in disc: return disc.split('/')[0] return disc
[docs] def get_comment(filepath: str) -> str: """ Get the comment from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The comment or empty string if not found. """ return _get_specific_tag(filepath, 'COMM::eng', ['COMMENT', 'Comment'])
[docs] def get_composer(filepath: str) -> str: """ Get the composer from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The composer or empty string if not found. """ return _get_specific_tag(filepath, 'TCOM', ['COMPOSER', 'Composer'])
[docs] def get_performer(filepath: str) -> str: """ Get the performer from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The performer or empty string if not found. """ return _get_specific_tag(filepath, 'TPE3', ['PERFORMER', 'Performer'])
[docs] def get_grouping(filepath: str) -> str: """ Get the grouping from an audio file. Args: filepath (str): Path to the audio file. Returns: str: The grouping or empty string if not found. """ return _get_specific_tag(filepath, 'TIT1', ['GROUPING', 'Grouping'])