- ClickableStarRating widget — 5 stars with hover preview, toggle to unrate - Rating field in MetadataEditorDialog (0–5 stars), saved to LibraryCache (0–100) - MetadataHandler writes rating to MP3 (POPM), M4A (\xa9rt), FLAC/Ogg (RATING)
893 lines
32 KiB
Python
893 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Metadata Handler Module for iPod Nano Transfer Tool
|
||
Handles metadata injection and album art embedding
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import logging
|
||
import struct
|
||
import base64
|
||
from typing import Optional, Dict, Any
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
from PIL import Image
|
||
from mutagen import File as MutagenFile
|
||
from mutagen.mp4 import MP4, MP4Cover
|
||
from mutagen.id3 import ID3, APIC, POPM, TIT2, TPE1, TALB, TRCK
|
||
from mutagen.easyid3 import EasyID3
|
||
|
||
from track_info import TrackInfo
|
||
from artwork.cache import cover_cache, xdg_cache_path
|
||
|
||
# Configure logging
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class MetadataHandler:
|
||
"""Class to handle metadata injection and album art embedding"""
|
||
|
||
def __init__(self, temp_dir: Optional[str] = None):
|
||
"""
|
||
Initialize the metadata handler
|
||
|
||
Args:
|
||
temp_dir: Directory for temporary files (optional)
|
||
"""
|
||
self.temp_dir = temp_dir or xdg_cache_path("thumbnails")
|
||
os.makedirs(self.temp_dir, exist_ok=True)
|
||
|
||
def download_thumbnail(self, thumbnail_url: str) -> Optional[str]:
|
||
"""
|
||
Download thumbnail image from URL
|
||
|
||
Args:
|
||
thumbnail_url: URL of the thumbnail image
|
||
|
||
Returns:
|
||
Path to downloaded thumbnail file, or None if download failed
|
||
"""
|
||
if not thumbnail_url:
|
||
logger.warning("No thumbnail URL provided")
|
||
return None
|
||
|
||
try:
|
||
logger.info(f"Downloading thumbnail: {thumbnail_url}")
|
||
response = requests.get(thumbnail_url, timeout=10)
|
||
response.raise_for_status()
|
||
|
||
# Save thumbnail to temporary file
|
||
thumbnail_path = os.path.join(self.temp_dir, f"thumbnail_{hash(thumbnail_url)}.jpg")
|
||
|
||
# Process image with PIL to ensure it's a valid format
|
||
img = Image.open(BytesIO(response.content))
|
||
img.save(thumbnail_path, "JPEG")
|
||
|
||
logger.info(f"Thumbnail saved to: {thumbnail_path}")
|
||
return thumbnail_path
|
||
|
||
except (requests.RequestException, IOError) as e:
|
||
logger.error(f"Failed to download thumbnail: {e}")
|
||
return None
|
||
|
||
def embed_metadata_m4a(self,
|
||
file_path: str,
|
||
track_info: TrackInfo,
|
||
thumbnail_path: Optional[str] = None) -> bool:
|
||
"""
|
||
Embed metadata and album art in M4A/AAC file
|
||
|
||
Args:
|
||
file_path: Path to M4A/AAC file
|
||
track_info: TrackInfo object with track metadata
|
||
thumbnail_path: Path to thumbnail image (optional)
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
logger.info(f"Embedding metadata in M4A file: {file_path}")
|
||
|
||
try:
|
||
audio = MP4(file_path)
|
||
|
||
# Clear existing tags
|
||
audio.clear()
|
||
|
||
# Add metadata tags
|
||
audio['\xa9nam'] = [track_info.title] # Title
|
||
audio['\xa9ART'] = [track_info.artist] # Artist
|
||
audio['\xa9alb'] = [track_info.album or track_info.artist] # Album
|
||
|
||
# Add track number if available
|
||
if track_info.track_number is not None:
|
||
audio['trkn'] = [(track_info.track_number, 0)]
|
||
|
||
# Add album art if available
|
||
if thumbnail_path:
|
||
with open(thumbnail_path, 'rb') as f:
|
||
album_art_data = f.read()
|
||
|
||
# Determine image format
|
||
if album_art_data.startswith(b'\xff\xd8'):
|
||
# JPEG
|
||
cover = MP4Cover(album_art_data, imageformat=MP4Cover.FORMAT_JPEG)
|
||
else:
|
||
# PNG
|
||
cover = MP4Cover(album_art_data, imageformat=MP4Cover.FORMAT_PNG)
|
||
|
||
audio['covr'] = [cover]
|
||
|
||
# Save changes
|
||
audio.save()
|
||
logger.info(f"Metadata embedded successfully: {file_path}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to embed metadata in M4A file: {e}")
|
||
return False
|
||
|
||
def embed_metadata_mp3(self,
|
||
file_path: str,
|
||
track_info: TrackInfo,
|
||
thumbnail_path: Optional[str] = None) -> bool:
|
||
"""
|
||
Embed metadata and album art in MP3 file
|
||
|
||
Args:
|
||
file_path: Path to MP3 file
|
||
track_info: TrackInfo object with track metadata
|
||
thumbnail_path: Path to thumbnail image (optional)
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
logger.info(f"Embedding metadata in MP3 file: {file_path}")
|
||
|
||
try:
|
||
# First try with EasyID3 for basic tags
|
||
try:
|
||
audio = EasyID3(file_path)
|
||
except:
|
||
# If the file doesn't have an ID3 tag, add one
|
||
audio = ID3()
|
||
audio.save(file_path)
|
||
audio = EasyID3(file_path)
|
||
|
||
# Clear existing tags
|
||
audio.clear()
|
||
|
||
# Add metadata tags
|
||
audio['title'] = track_info.title
|
||
audio['artist'] = track_info.artist
|
||
audio['album'] = track_info.album or track_info.artist
|
||
|
||
# Add track number if available
|
||
if track_info.track_number is not None:
|
||
audio['tracknumber'] = str(track_info.track_number)
|
||
|
||
# Save basic tags
|
||
audio.save()
|
||
|
||
# Now add album art using ID3
|
||
if thumbnail_path:
|
||
audio = ID3(file_path)
|
||
|
||
with open(thumbnail_path, 'rb') as f:
|
||
album_art_data = f.read()
|
||
|
||
# Add album art
|
||
audio.add(
|
||
APIC(
|
||
encoding=3, # UTF-8
|
||
mime='image/jpeg',
|
||
type=3, # Cover (front)
|
||
desc='Cover',
|
||
data=album_art_data
|
||
)
|
||
)
|
||
|
||
# Save with album art
|
||
audio.save()
|
||
|
||
logger.info(f"Metadata embedded successfully: {file_path}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to embed metadata in MP3 file: {e}")
|
||
return False
|
||
|
||
def process_file(self,
|
||
file_path: str,
|
||
track_info: TrackInfo) -> bool:
|
||
"""
|
||
Process audio file to embed metadata and album art
|
||
|
||
Args:
|
||
file_path: Path to audio file
|
||
track_info: TrackInfo object with track metadata
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
logger.info(f"Processing metadata for file: {file_path}")
|
||
|
||
# Download thumbnail if available, or use cached cover from source
|
||
thumbnail_path = None
|
||
if track_info.thumbnail_url:
|
||
thumbnail_path = self.download_thumbnail(track_info.thumbnail_url)
|
||
elif track_info.cover_path and os.path.exists(track_info.cover_path):
|
||
thumbnail_path = track_info.cover_path
|
||
|
||
# Determine file type and process accordingly
|
||
file_ext = Path(file_path).suffix.lower()
|
||
|
||
if file_ext in ['.m4a', '.aac', '.mp4']:
|
||
return self.embed_metadata_m4a(file_path, track_info, thumbnail_path)
|
||
elif file_ext == '.mp3':
|
||
return self.embed_metadata_mp3(file_path, track_info, thumbnail_path)
|
||
else:
|
||
logger.warning(f"Unsupported file format: {file_ext}")
|
||
return False
|
||
|
||
def extract_tags(self, file_path: str) -> Optional[TrackInfo]:
|
||
"""
|
||
Extract metadata from any supported audio file.
|
||
|
||
Uses mutagen to read tags from FLAC, MP3, OGG, OPUS, M4A, WAV, APE, etc.
|
||
Also extracts embedded cover art if present.
|
||
|
||
Args:
|
||
file_path: Path to audio file
|
||
|
||
Returns:
|
||
TrackInfo with extracted metadata, or None if file can't be read
|
||
"""
|
||
import struct
|
||
import base64
|
||
|
||
logger.info(f"Extracting metadata from: {file_path}")
|
||
|
||
try:
|
||
audio = MutagenFile(file_path)
|
||
if audio is None:
|
||
logger.warning(f"Cannot read tags from: {file_path}")
|
||
return self._parse_filename_trackinfo(
|
||
os.path.splitext(os.path.basename(file_path))[0], file_path
|
||
)
|
||
|
||
filename = os.path.basename(file_path)
|
||
base_name = os.path.splitext(filename)[0]
|
||
|
||
tags = audio.tags
|
||
|
||
# Extract common fields - handle both vorbis (FLAC/OGG) and MP4 tags
|
||
title = self._safe_get_tag(tags, 'title', '\xa9nam')
|
||
artist = self._safe_get_tag(tags, 'artist', '\xa9ART')
|
||
album = self._safe_get_tag(tags, 'album', '\xa9alb')
|
||
album_artist = self._safe_get_tag(tags, 'albumartist', 'aART')
|
||
genre = self._safe_get_tag(tags, 'genre', '\xa9gen')
|
||
date_str = self._safe_get_tag(tags, 'date', '\xa9day')
|
||
|
||
# Track number
|
||
track_number = 0
|
||
track_count = 0
|
||
for key in ('tracknumber', 'TRK', 'trkn'):
|
||
val = self._safe_get_tag_value(tags, key)
|
||
if val is not None:
|
||
if '/' in val:
|
||
parts = val.split('/')
|
||
track_number = int(parts[0]) if parts[0].isdigit() else 0
|
||
track_count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
||
else:
|
||
try:
|
||
track_number = int(val)
|
||
except ValueError:
|
||
pass
|
||
if track_number:
|
||
break
|
||
|
||
# M4A trkn is stored as list of tuples - check directly
|
||
if not track_number and tags is not None:
|
||
try:
|
||
if 'trkn' in tags:
|
||
trkn = tags['trkn']
|
||
if isinstance(trkn, list) and trkn:
|
||
t = trkn[0]
|
||
if isinstance(t, tuple) and len(t) >= 1:
|
||
track_number = t[0]
|
||
if len(t) >= 2:
|
||
track_count = t[1]
|
||
elif isinstance(trkn, tuple) and len(trkn) >= 1:
|
||
track_number = trkn[0]
|
||
if len(trkn) >= 2:
|
||
track_count = trkn[1]
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Disc number
|
||
disc_number = 0
|
||
for key in ('discnumber', 'DISC', 'disk'):
|
||
val = self._safe_get_tag_value(tags, key)
|
||
if val is not None:
|
||
if '/' in val:
|
||
try:
|
||
disc_number = int(val.split('/')[0])
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
try:
|
||
disc_number = int(val)
|
||
except ValueError:
|
||
pass
|
||
if disc_number:
|
||
break
|
||
|
||
# Duration
|
||
duration = int(audio.info.length) if hasattr(audio, 'info') and audio.info.length else 0
|
||
|
||
# Extract cover art
|
||
cover_data = self._extract_cover(audio, tags)
|
||
thumbnail_path = None
|
||
if cover_data:
|
||
thumbnail_path = os.path.join(self.temp_dir, f"cover_{hash(file_path)}.jpg")
|
||
self._save_cover_as_jpeg(cover_data, thumbnail_path)
|
||
|
||
# Parse year from date
|
||
year = 0
|
||
if date_str:
|
||
try:
|
||
year = int(str(date_str)[:4])
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# If essential tags are missing, fall back to filename
|
||
if not title:
|
||
fallback = self._parse_filename_trackinfo(base_name)
|
||
title = title or fallback.title
|
||
artist = artist or fallback.artist
|
||
|
||
# Cache cover art for the player UI
|
||
cover_path = None
|
||
if cover_data:
|
||
cover_path = cover_cache.put(artist or "Unknown Artist", album or "", cover_data)
|
||
|
||
return TrackInfo(
|
||
source_id=f"local_{hash(file_path)}",
|
||
title=title or base_name,
|
||
artist=artist or "Unknown Artist",
|
||
album=album or "",
|
||
thumbnail_url="",
|
||
duration=duration,
|
||
track_number=track_number if track_number else None,
|
||
genre=genre,
|
||
download_path=file_path,
|
||
cover_path=cover_path,
|
||
album_artist=album_artist,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to extract tags from {file_path}: {e}")
|
||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||
return self._parse_filename_trackinfo(base_name, file_path)
|
||
|
||
def _safe_get_tag(self, tags, easy_key: str, mp4_key: str) -> Optional[str]:
|
||
"""Get a tag value safely, handling vorbis, ID3, and MP4 tag formats."""
|
||
if tags is None:
|
||
return None
|
||
|
||
# Try MP4 key first (for MP4/M4A files)
|
||
try:
|
||
if mp4_key in tags:
|
||
val = tags[mp4_key]
|
||
if isinstance(val, list) and val:
|
||
return str(val[0])
|
||
return str(val)
|
||
except ValueError:
|
||
pass # vorbis dict raises ValueError for invalid keys
|
||
|
||
# Try vorbis comment key (case-insensitive)
|
||
try:
|
||
easy_lower = easy_key.lower()
|
||
for k in tags.keys():
|
||
if k.lower() == easy_lower:
|
||
val = tags[k]
|
||
if isinstance(val, list) and val:
|
||
return str(val[0])
|
||
return str(val)
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
# Try ID3 frame mapping for common tags
|
||
id3_map = {
|
||
'title': 'TIT2',
|
||
'artist': 'TPE1',
|
||
'album': 'TALB',
|
||
'albumartist': 'TPE2',
|
||
'genre': 'TCON',
|
||
'date': 'TDRC',
|
||
'comment': 'COMM',
|
||
}
|
||
frame_id = id3_map.get(easy_key.lower())
|
||
if frame_id and hasattr(tags, 'getall'):
|
||
try:
|
||
frames = tags.getall(frame_id)
|
||
if frames:
|
||
return str(frames[0])
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
def _safe_get_tag_value(self, tags, key: str) -> Optional[str]:
|
||
"""Get raw tag value safely."""
|
||
if tags is None:
|
||
return None
|
||
|
||
# Direct check
|
||
try:
|
||
if key in tags:
|
||
val = tags[key]
|
||
if isinstance(val, list) and val:
|
||
return str(val[0])
|
||
return str(val)
|
||
except (ValueError, KeyError, TypeError):
|
||
pass
|
||
|
||
# Case-insensitive for vorbis
|
||
try:
|
||
key_lower = key.lower()
|
||
for k in tags.keys():
|
||
if k.lower() == key_lower:
|
||
val = tags[k]
|
||
if isinstance(val, list) and val:
|
||
return str(val[0])
|
||
return str(val)
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
# ID3 frame mapping
|
||
id3_map = {
|
||
'tracknumber': 'TRCK',
|
||
'discnumber': 'TPOS',
|
||
}
|
||
frame_id = id3_map.get(key.lower())
|
||
if frame_id and hasattr(tags, 'getall'):
|
||
try:
|
||
frames = tags.getall(frame_id)
|
||
if frames:
|
||
return str(frames[0])
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
def _extract_cover(self, audio, tags) -> Optional[bytes]:
|
||
"""Extract cover art data from audio file tags."""
|
||
# MP3 (ID3v2) - APIC frames
|
||
if hasattr(tags, 'getall'):
|
||
try:
|
||
for frame in tags.getall('APIC'):
|
||
return frame.data
|
||
except Exception:
|
||
pass
|
||
|
||
# FLAC / Vorbis comments - METADATA_BLOCK_PICTURE
|
||
try:
|
||
if 'metadata_block_picture' in tags:
|
||
pic_data = tags['metadata_block_picture']
|
||
if isinstance(pic_data, list):
|
||
pic_data = pic_data[0]
|
||
pic_data = str(pic_data)
|
||
decoded = base64.b64decode(pic_data)
|
||
# Parse the picture block: type(4), mime_len(4), mime, desc_len(4), desc, w(4), h(4), d(4), colors(4), data_len(4), data
|
||
pos = 0
|
||
pos += 4 # picture type
|
||
mime_len = struct.unpack('>I', decoded[pos:pos+4])[0]
|
||
pos += 4 + mime_len # mime string
|
||
desc_len = struct.unpack('>I', decoded[pos:pos+4])[0]
|
||
pos += 4 + desc_len # description
|
||
pos += 20 # width(4) + height(4) + depth(4) + colors(4) + data_len(4)
|
||
return decoded[pos:]
|
||
except Exception:
|
||
pass
|
||
|
||
# MP4/M4A - covr
|
||
try:
|
||
if 'covr' in tags:
|
||
cover_list = tags['covr']
|
||
if isinstance(cover_list, list) and cover_list:
|
||
return bytes(cover_list[0])
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
def _save_cover_as_jpeg(self, cover_data: bytes, output_path: str) -> None:
|
||
"""Save cover art data as JPEG."""
|
||
try:
|
||
from PIL import Image
|
||
from io import BytesIO
|
||
|
||
logger.debug(f"Saving cover art: {len(cover_data)} bytes")
|
||
stream = BytesIO(cover_data)
|
||
img = Image.open(stream)
|
||
img = img.convert('RGB')
|
||
max_size = 600
|
||
if img.width > max_size or img.height > max_size:
|
||
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
||
img.save(output_path, 'JPEG', quality=85)
|
||
logger.debug(f"Cover art saved to: {output_path}")
|
||
except Exception as e:
|
||
logger.warning(f"Could not save cover art: {e}")
|
||
logger.debug(f"Cover data length: {len(cover_data)}, first bytes: {cover_data[:4].hex()}")
|
||
|
||
def _parse_filename_trackinfo(self, filename: str, file_path: Optional[str] = None) -> TrackInfo:
|
||
"""Create TrackInfo by parsing filename as fallback."""
|
||
title = filename
|
||
artist = "Unknown"
|
||
|
||
if " - " in filename:
|
||
artist, title = filename.split(" - ", 1)
|
||
|
||
return TrackInfo(
|
||
source_id=f"local_{hash(file_path or filename)}",
|
||
title=title,
|
||
artist=artist,
|
||
album="",
|
||
thumbnail_url="",
|
||
duration=0,
|
||
download_path=file_path,
|
||
)
|
||
|
||
def update_tags(self, file_path: str, tags: Dict[str, Any],
|
||
new_cover_path: Optional[str] = None) -> bool:
|
||
"""
|
||
Update metadata tags in an audio file.
|
||
|
||
Args:
|
||
file_path: Path to audio file
|
||
tags: Dict of tag_name -> value
|
||
Supported keys: title, artist, album, album_artist, genre,
|
||
date, track_number, track_total, disc_number, disc_total,
|
||
comment, rating (0‑5 stars)
|
||
new_cover_path: Path to new cover image, None to keep existing,
|
||
empty string '' to remove cover art
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
from mutagen.flac import FLAC, Picture as FLACPicture
|
||
from mutagen.oggvorbis import OggVorbis
|
||
from mutagen.oggopus import OggOpus
|
||
|
||
file_ext = Path(file_path).suffix.lower()
|
||
|
||
try:
|
||
if file_ext in ('.m4a', '.aac', '.mp4'):
|
||
return self._update_mp4_tags(file_path, tags, new_cover_path)
|
||
elif file_ext == '.mp3':
|
||
return self._update_mp3_tags(file_path, tags, new_cover_path)
|
||
elif file_ext == '.flac':
|
||
return self._update_vorbis_tags(file_path, tags, new_cover_path, use_picture_block=True)
|
||
elif file_ext in ('.ogg', '.opus'):
|
||
return self._update_vorbis_tags(file_path, tags, new_cover_path, use_picture_block=False)
|
||
else:
|
||
logger.warning(f"Unsupported format for tag update: {file_ext}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"Failed to update tags in {file_path}: {e}")
|
||
return False
|
||
|
||
def _load_cover_data(self, cover_path: str) -> Optional[bytes]:
|
||
"""Load cover image from path, convert to JPEG if needed."""
|
||
try:
|
||
img = Image.open(cover_path)
|
||
if img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
buf = BytesIO()
|
||
img.save(buf, 'JPEG', quality=90)
|
||
return buf.getvalue()
|
||
except Exception as e:
|
||
logger.error(f"Failed to load cover image {cover_path}: {e}")
|
||
return None
|
||
|
||
def _update_mp4_tags(self, file_path: str, tags: Dict[str, Any],
|
||
new_cover_path: Optional[str]) -> bool:
|
||
audio = MP4(file_path)
|
||
|
||
text_fields = {
|
||
'title': '\xa9nam',
|
||
'artist': '\xa9ART',
|
||
'album': '\xa9alb',
|
||
'album_artist': 'aART',
|
||
'genre': '\xa9gen',
|
||
'date': '\xa9day',
|
||
'comment': '\xa9cmt',
|
||
}
|
||
for key, mp4_key in text_fields.items():
|
||
if key in tags:
|
||
val = tags[key]
|
||
if val:
|
||
audio[mp4_key] = [str(val)]
|
||
elif mp4_key in audio:
|
||
del audio[mp4_key]
|
||
|
||
if 'track_number' in tags or 'track_total' in tags:
|
||
trkn_val = list(audio.get('trkn', [(0, 0)])[0]) if 'trkn' in audio else [0, 0]
|
||
if 'track_number' in tags:
|
||
trkn_val[0] = tags['track_number'] or 0
|
||
if 'track_total' in tags:
|
||
trkn_val[1] = tags['track_total'] or 0
|
||
if trkn_val[0] or trkn_val[1]:
|
||
audio['trkn'] = [tuple(trkn_val)]
|
||
elif 'trkn' in audio:
|
||
del audio['trkn']
|
||
|
||
if 'disc_number' in tags or 'disc_total' in tags:
|
||
disk_val = list(audio.get('disk', [(0, 0)])[0]) if 'disk' in audio else [0, 0]
|
||
if 'disc_number' in tags:
|
||
disk_val[0] = tags['disc_number'] or 0
|
||
if 'disc_total' in tags:
|
||
disk_val[1] = tags['disc_total'] or 0
|
||
if disk_val[0] or disk_val[1]:
|
||
audio['disk'] = [tuple(disk_val)]
|
||
elif 'disk' in audio:
|
||
del audio['disk']
|
||
|
||
if 'rating' in tags:
|
||
val = tags['rating'] * 20 # stars → 0-100
|
||
if val > 0:
|
||
audio['\xa9rt'] = [val]
|
||
elif '\xa9rt' in audio:
|
||
del audio['\xa9rt']
|
||
|
||
if new_cover_path == '':
|
||
if 'covr' in audio:
|
||
del audio['covr']
|
||
elif new_cover_path is not None:
|
||
data = self._load_cover_data(new_cover_path)
|
||
if data:
|
||
cover = MP4Cover(data, imageformat=MP4Cover.FORMAT_JPEG)
|
||
audio['covr'] = [cover]
|
||
|
||
audio.save()
|
||
return True
|
||
|
||
def _update_mp3_tags(self, file_path: str, tags: Dict[str, Any],
|
||
new_cover_path: Optional[str]) -> bool:
|
||
try:
|
||
audio = EasyID3(file_path)
|
||
except Exception:
|
||
audio = ID3()
|
||
audio.save(file_path)
|
||
audio = EasyID3(file_path)
|
||
|
||
easy_fields = {
|
||
'title': 'title',
|
||
'artist': 'artist',
|
||
'album': 'album',
|
||
'album_artist': 'albumartist',
|
||
'genre': 'genre',
|
||
'date': 'date',
|
||
}
|
||
for key, easy_key in easy_fields.items():
|
||
if key in tags:
|
||
val = tags[key]
|
||
if val:
|
||
audio[easy_key] = str(val)
|
||
elif easy_key in audio:
|
||
del audio[easy_key]
|
||
|
||
if 'comment' in tags:
|
||
try:
|
||
audio['comment'] = str(tags['comment']) if tags['comment'] else ''
|
||
except Exception:
|
||
pass
|
||
|
||
if 'track_number' in tags or 'track_total' in tags:
|
||
current = audio.get('tracknumber', [''])[0] if 'tracknumber' in audio else ''
|
||
tn, tt = self._parse_slash_value(current)
|
||
if 'track_number' in tags:
|
||
tn = tags['track_number'] or 0
|
||
if 'track_total' in tags:
|
||
tt = tags['track_total'] or 0
|
||
if tn or tt:
|
||
audio['tracknumber'] = [f"{tn}/{tt}" if tt else str(tn)]
|
||
elif 'tracknumber' in audio:
|
||
del audio['tracknumber']
|
||
|
||
if 'disc_number' in tags or 'disc_total' in tags:
|
||
current = audio.get('discnumber', [''])[0] if 'discnumber' in audio else ''
|
||
dn, dt = self._parse_slash_value(current)
|
||
if 'disc_number' in tags:
|
||
dn = tags['disc_number'] or 0
|
||
if 'disc_total' in tags:
|
||
dt = tags['disc_total'] or 0
|
||
if dn or dt:
|
||
audio['discnumber'] = [f"{dn}/{dt}" if dt else str(dn)]
|
||
elif 'discnumber' in audio:
|
||
del audio['discnumber']
|
||
|
||
if 'rating' in tags:
|
||
stars = tags['rating']
|
||
popm_val = [0, 64, 128, 192, 224, 255][min(stars, 5)]
|
||
audio = ID3(file_path)
|
||
audio.delall("POPM")
|
||
if popm_val > 0:
|
||
audio.add(POPM(email="", rating=popm_val))
|
||
audio.save(file_path)
|
||
audio = EasyID3(file_path)
|
||
|
||
audio.save()
|
||
|
||
if new_cover_path is not None:
|
||
audio = ID3(file_path)
|
||
if new_cover_path == '':
|
||
audio.delall('APIC')
|
||
else:
|
||
data = self._load_cover_data(new_cover_path)
|
||
if data:
|
||
audio.delall('APIC')
|
||
audio.add(APIC(encoding=3, mime='image/jpeg', type=3,
|
||
desc='Cover', data=data))
|
||
audio.save()
|
||
|
||
return True
|
||
|
||
def _parse_slash_value(self, current: str) -> Tuple[int, int]:
|
||
"""Parse '5/12' format into (5, 12)."""
|
||
try:
|
||
parts = str(current).split('/')
|
||
first = int(parts[0]) if parts[0].strip().isdigit() else 0
|
||
second = int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else 0
|
||
return first, second
|
||
except Exception:
|
||
return 0, 0
|
||
|
||
def _update_vorbis_tags(self, file_path: str, tags: Dict[str, Any],
|
||
new_cover_path: Optional[str],
|
||
use_picture_block: bool = False) -> bool:
|
||
from mutagen.flac import FLAC, Picture as FLACPicture
|
||
from mutagen.oggvorbis import OggVorbis
|
||
from mutagen.oggopus import OggOpus
|
||
|
||
ext = Path(file_path).suffix.lower()
|
||
if ext == '.flac':
|
||
audio = FLAC(file_path)
|
||
elif ext == '.opus':
|
||
audio = OggOpus(file_path)
|
||
else:
|
||
audio = OggVorbis(file_path)
|
||
|
||
if audio.tags is None:
|
||
audio.add_tags()
|
||
|
||
vorbis_fields = {
|
||
'title': 'TITLE',
|
||
'artist': 'ARTIST',
|
||
'album': 'ALBUM',
|
||
'album_artist': 'ALBUMARTIST',
|
||
'genre': 'GENRE',
|
||
'date': 'DATE',
|
||
'comment': 'COMMENT',
|
||
}
|
||
for key, vorbis_key in vorbis_fields.items():
|
||
if key in tags:
|
||
val = tags[key]
|
||
if val:
|
||
audio.tags[vorbis_key] = str(val)
|
||
elif vorbis_key in audio.tags:
|
||
try:
|
||
del audio.tags[vorbis_key]
|
||
except (KeyError, TypeError):
|
||
pass
|
||
|
||
if 'track_number' in tags or 'track_total' in tags:
|
||
current = audio.tags.get('TRACKNUMBER', [''])[0] if 'TRACKNUMBER' in audio.tags else ''
|
||
tn, tt = self._parse_slash_value(current)
|
||
if 'track_number' in tags:
|
||
tn = tags['track_number'] or 0
|
||
if 'track_total' in tags:
|
||
tt = tags['track_total'] or 0
|
||
if tn or tt:
|
||
audio.tags['TRACKNUMBER'] = f"{tn}/{tt}" if tt else str(tn)
|
||
elif 'TRACKNUMBER' in audio.tags:
|
||
try:
|
||
del audio.tags['TRACKNUMBER']
|
||
except (KeyError, TypeError):
|
||
pass
|
||
|
||
if 'disc_number' in tags or 'disc_total' in tags:
|
||
current = audio.tags.get('DISCNUMBER', [''])[0] if 'DISCNUMBER' in audio.tags else ''
|
||
dn, dt = self._parse_slash_value(current)
|
||
if 'disc_number' in tags:
|
||
dn = tags['disc_number'] or 0
|
||
if 'disc_total' in tags:
|
||
dt = tags['disc_total'] or 0
|
||
if dn or dt:
|
||
audio.tags['DISCNUMBER'] = f"{dn}/{dt}" if dt else str(dn)
|
||
elif 'DISCNUMBER' in audio.tags:
|
||
try:
|
||
del audio.tags['DISCNUMBER']
|
||
except (KeyError, TypeError):
|
||
pass
|
||
|
||
if 'rating' in tags:
|
||
val = str(tags['rating'] * 20) # stars → "0" … "100"
|
||
if int(val) > 0:
|
||
audio.tags['RATING'] = val
|
||
elif 'RATING' in audio.tags:
|
||
try:
|
||
del audio.tags['RATING']
|
||
except (KeyError, TypeError):
|
||
pass
|
||
|
||
if new_cover_path is not None:
|
||
self._clear_cover_vorbis(audio)
|
||
if new_cover_path != '':
|
||
data = self._load_cover_data(new_cover_path)
|
||
if data:
|
||
self._embed_cover_vorbis(audio, data)
|
||
elif use_picture_block:
|
||
pass
|
||
else:
|
||
pass
|
||
|
||
audio.save()
|
||
return True
|
||
|
||
def _clear_cover_vorbis(self, audio) -> None:
|
||
"""Remove all cover art from Vorbis/FLAC file."""
|
||
if audio.tags is None:
|
||
return
|
||
if 'METADATA_BLOCK_PICTURE' in audio.tags:
|
||
del audio.tags['METADATA_BLOCK_PICTURE']
|
||
if 'COVERART' in audio.tags:
|
||
del audio.tags['COVERART']
|
||
if hasattr(audio, 'clear_pictures'):
|
||
audio.clear_pictures()
|
||
|
||
def _embed_cover_vorbis(self, audio, data: bytes) -> None:
|
||
"""Embed JPEG cover art into Vorbis/FLAC file."""
|
||
from mutagen.flac import Picture as FLACPicture
|
||
|
||
pic = FLACPicture()
|
||
pic.type = 3 # Cover (front)
|
||
pic.mime = 'image/jpeg'
|
||
pic.desc = 'Cover'
|
||
pic.width = 0
|
||
pic.height = 0
|
||
pic.depth = 24
|
||
pic.data = data
|
||
pic_data = pic.write()
|
||
|
||
if hasattr(audio, 'add_picture'):
|
||
audio.clear_pictures()
|
||
audio.add_picture(pic)
|
||
else:
|
||
encoded = base64.b64encode(pic_data).decode('ascii')
|
||
audio.tags['METADATA_BLOCK_PICTURE'] = encoded
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Example usage
|
||
handler = MetadataHandler()
|
||
|
||
# Test metadata embedding
|
||
# handler.process_file(
|
||
# "test.m4a",
|
||
# TrackInfo(
|
||
# video_id="dQw4w9WgXcQ",
|
||
# title="Never Gonna Give You Up",
|
||
# artist="Rick Astley",
|
||
# album="Whenever You Need Somebody",
|
||
# thumbnail_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
|
||
# duration=213,
|
||
# track_number=1
|
||
# )
|
||
# )
|