diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index 41ac161..60710a0 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -15,9 +15,9 @@ import shutil from typing import Optional, Dict, Any from mutagen import File as MutagenFile +from mutagen.easyid3 import EasyID3 from mutagen.mp4 import MP4 from mutagen.mp3 import MP3 -from mutagen.id3 import ID3 logger = logging.getLogger(__name__) @@ -117,51 +117,118 @@ class Nano7Database: else: info["audio_format"] = 1 - # Extract tags - for tag_name, file_key in [ - ("title", "title"), - ("artist", "artist"), - ("album", "album"), - ("albumartist", "album_artist"), - ("composer", "composer"), - ("genre", "genre"), - ("comment", "comment"), - ]: - if tag_name in audio: - val = audio[tag_name] - if isinstance(val, list): - val = val[0] - if isinstance(val, str): - info[file_key] = val - - # Track/disc numbers - for tag_name, file_key in [ - ("tracknumber", "track_number"), - ("discnumber", "disc_number"), - ]: - if tag_name in audio: - val = audio[tag_name] - if isinstance(val, list): - val = val[0] - if isinstance(val, str): - parts = val.split("/") - info[file_key] = int(parts[0]) if parts[0].isdigit() else 0 - if len(parts) > 1: - info[file_key + "_count"] = ( - int(parts[1]) if parts[1].isdigit() else 0 - ) - elif isinstance(val, int): - info[file_key] = val - - # Year - if "date" in audio: - val = audio["date"] - if isinstance(val, list): - val = val[0] - if isinstance(val, str) and len(val) >= 4: - info["year"] = int(val[:4]) - elif isinstance(val, int): - info["year"] = val + # Extract tags - handle different formats + ext = os.path.splitext(filepath)[1].lower() + + # Use EasyID3 for MP3 files (supports easy key access) + if ext == ".mp3": + try: + easy = EasyID3(filepath) + for tag_name, file_key in [ + ("title", "title"), + ("artist", "artist"), + ("album", "album"), + ("albumartist", "albumartist"), + ("composer", "composer"), + ("genre", "genre"), + ("comment", "comment"), + ]: + if tag_name in easy: + val = easy[tag_name] + if isinstance(val, list) and val: + info[file_key] = str(val[0]) + + # Track/disc numbers + for tag_name, file_key in [ + ("tracknumber", "track_number"), + ("discnumber", "disc_number"), + ]: + if tag_name in easy: + val = easy[tag_name] + if isinstance(val, list) and val: + val = str(val[0]) + if '/' in val: + parts = val.split('/') + info[file_key] = int(parts[0]) if parts[0].isdigit() else 0 + elif val.isdigit(): + info[file_key] = int(val) + + # Date/year + if "date" in easy: + val = easy["date"] + if isinstance(val, list) and val: + date_str = str(val[0]) + if len(date_str) >= 4: + info["year"] = int(date_str[:4]) + except Exception as e: + logger.debug(f"EasyID3 failed for {filepath}: {e}") + + # MP4/M4A uses MutagenFile directly + elif ext in (".m4a", ".aac", ".mp4"): + if audio and audio.tags: + tags = audio.tags + for tag_name, mp4_key, file_key in [ + ("title", "\xa9nam", "title"), + ("artist", "\xa9ART", "artist"), + ("album", "\xa9alb", "album"), + ]: + if mp4_key in tags: + val = tags[mp4_key] + if isinstance(val, list) and val: + info[file_key] = str(val[0]) + if "trkn" in tags: + trkn = tags["trkn"] + if isinstance(trkn, list) and trkn: + t = trkn[0] + if isinstance(t, tuple) and len(t) >= 1: + info["track_number"] = t[0] + else: + # FLAC/OGG/Vorbis - use MutagenFile with tag access + if audio and audio.tags: + tags = audio.tags + for tag_name, file_key in [ + ("title", "title"), + ("artist", "artist"), + ("album", "album"), + ("albumartist", "album_artist"), + ("composer", "composer"), + ("genre", "genre"), + ("comment", "comment"), + ]: + try: + if tag_name in tags: + val = tags[tag_name] + if isinstance(val, list) and val: + info[file_key] = str(val[0]) + except (ValueError, TypeError): + pass + + for tag_name, file_key in [ + ("tracknumber", "track_number"), + ("discnumber", "disc_number"), + ]: + try: + if tag_name in tags: + val = tags[tag_name] + if isinstance(val, list) and val: + val = str(val[0]) + if '/' in val: + parts = val.split('/') + info[file_key] = int(parts[0]) if parts[0].isdigit() else 0 + elif val.isdigit(): + info[file_key] = int(val) + except (ValueError, TypeError): + pass + + try: + if "date" in tags: + val = tags["date"] + if isinstance(val, list) and val: + date_str = str(val[0]) + if len(date_str) >= 4: + info["year"] = int(date_str[:4]) + except (ValueError, TypeError): + pass return info diff --git a/src/main.py b/src/main.py index fdcff1a..3d0ea3f 100644 --- a/src/main.py +++ b/src/main.py @@ -108,27 +108,16 @@ class WorkerThread(QThread): # Filter out tracks without download paths valid_tracks = [track for track in tracks if hasattr(track, 'download_path') and track.download_path and os.path.exists(track.download_path)] - # Process local files into TrackInfo objects + # Process local files: extract real metadata from tags + metadata_handler = MetadataHandler() for f in local_files: if os.path.exists(f): - filename = os.path.basename(f) - title = os.path.splitext(filename)[0] - # Try to parse "Artist - Title" format - if " - " in title: - artist, track_title = title.split(" - ", 1) - else: - artist = "Unknown" - track_title = title - track = TrackInfo( - video_id=f"local_{hash(f)}", - title=track_title, - artist=artist, - album="", - thumbnail_url="", - duration=0, - download_path=f - ) - valid_tracks.append(track) + # Try to extract metadata from file tags first + track = metadata_handler.extract_tags(f) + if track: + # Ensure download_path is set + track.download_path = f + valid_tracks.append(track) if not valid_tracks: self.finished_signal.emit(False, "No valid tracks to convert", None) diff --git a/src/metadata_handler.py b/src/metadata_handler.py index 5dc4299..a8d5864 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -5,14 +5,18 @@ Handles metadata injection and album art embedding """ import os +import sys import logging import tempfile +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, TIT2, TPE1, TALB, TRCK from mutagen.easyid3 import EasyID3 @@ -227,6 +231,308 @@ class MetadataHandler: 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 + + return TrackInfo( + video_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, + download_path=file_path, + ) + + 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( + video_id=f"local_{hash(file_path or filename)}", + title=title, + artist=artist, + album="", + thumbnail_url="", + duration=0, + download_path=file_path, + ) + def enhance_metadata_with_musicbrainz(self, track_info: TrackInfo) -> TrackInfo: """ Enhance track metadata using MusicBrainz API