diff --git a/README.md b/README.md index a7460d7..89f096e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,73 @@ # Auto ID3 Tagger -This script will read all files in the provided folder (MUSIC_FOLDER) and identify the songs using Shazam. -It will then collect some information about the songs, including album art, and write this information to the ID3 tags -of the MP3 file. -### Packages used -[ShazamIO](https://github.com/dotX12/ShazamIO) - Used to identify the songs +This script automatically identifies MP3 files in a directory (recursively) and updates their ID3 tags with accurate metadata. -[EyeD3](https://eyed3.readthedocs.io/en/latest/index.html) - Used to write the ID3 tags \ No newline at end of file +## Features + +* **Recursively Scans Directories**: Processes all MP3 files in the given folder and its subfolders. +* **Multiple Recognition Providers**: + * **Shazam**: Primary recognition method (fast, accurate). + * **AcoustID**: Fallback method using audio fingerprinting (requires free API key). +* **Enhanced Metadata**: Uses **MusicBrainz** to fetch standardized metadata (Correct Album, Release Year, Genre) after initial identification. +* **Automatic Tagging**: Updates Artist, Title, Album, Year, Genre, and Cover Art. +* **Corrupted Tag Fixer**: Automatically detects and fixes corrupted ID3 headers (e.g., UTF-16 surrogate errors) that crash standard libraries. + +## Prerequisites + +1. **Python 3.12+** (Tested on Python 3.12.10) +2. **FFmpeg**: Required for audio processing. + ```powershell + winget install -e --id Gyan.FFmpeg + ``` +3. **Chromaprint (fpcalc)**: Required for AcoustID fingerprinting. + ```powershell + winget install -e --id ACOUSTID.fpcalc + ``` + +## Installation + +1. **Clone the repository**: + ```powershell + git clone https://github.com/ggfto/Auto-ID3-Tagger.git + cd Auto-ID3-Tagger + ``` + +2. **Create a Virtual Environment**: + ```powershell + python -m venv venv + .\venv\Scripts\Activate + ``` + +3. **Install Python Dependencies**: + ```powershell + pip install -r requirements.txt + pip install musicbrainzngs pyacoustid + ``` + +## Usage + +Activate your virtual environment (if not already active): +```powershell +.\venv\Scripts\Activate +``` + +Run the script by providing the path to your music folder: + +**Basic Usage (Shazam + MusicBrainz)**: +```powershell +python id_song.py "C:\Path\To\Your\Music" +``` + +**Advanced Usage (With AcoustID Fallback)**: +Obtain a free API Key from [AcoustID.org](https://acoustid.org/) and run: +```powershell +python id_song.py "C:\Path\To\Your\Music" --acoustid-key "YOUR_API_KEY" +``` + +## Troubleshooting + +### "RuntimeWarning: Couldn't find ffmpeg or avconv" +Ensure FFmpeg is installed and added to your system PATH. Restart your terminal after installing. + +### "UnicodeDecodeError: 'utf-16-le' codec can't decode..." +The script includes a fix for this. It will automatically detect the corrupted tag, strip the bad header, and retry processing the file. \ No newline at end of file diff --git a/id_song.py b/id_song.py index 10b1890..6deeeb6 100644 --- a/id_song.py +++ b/id_song.py @@ -1,41 +1,70 @@ import asyncio import os import urllib.request - +import argparse import eyed3 import eyed3.plugins.art - +import musicbrainzngs +import acoustid from shazamio import Shazam, Serialize -MUSIC_FOLDER = "" - - -async def main(): - shazam = Shazam() - files = os.listdir(MUSIC_FOLDER) - for file in files: - print(file) - # Loop over files and filter on MP3 files - path = os.path.join(MUSIC_FOLDER, file) - if not os.path.isfile(path): - continue - if path[-3:] != "mp3": - continue - - # Identify song using Shazam - out = await shazam.recognize_song(path) +# Initialize MusicBrainz user agent +musicbrainzngs.set_useragent("AutoID3Tagger", "0.2", "https://github.com/ggfto/Auto-ID3-Tagger") + +def remove_corrupted_tag(path): + """ + Manually removes ID3v2 tag if eyed3 fails to read it due to encoding errors. + """ + try: + with open(path, 'rb') as f: + header = f.read(10) + + if header.startswith(b'ID3'): + # Synchsafe integer conversion + size = (header[6] << 21) | (header[7] << 14) | (header[8] << 7) | header[9] + tag_size = 10 + size + + # Check flags for footer (bit 4) + flags = header[5] + if (flags & 0x10): + tag_size += 10 + + # Safety check: don't delete if tag size seems huge (larger than file) + file_size = os.path.getsize(path) + if tag_size >= file_size: + print(f"Warning: Tag size {tag_size} seems invalid for {path} (size: {file_size}). Skipping removal.") + return False + + with open(path, 'rb') as f: + f.seek(tag_size) + audio_data = f.read() + + with open(path, 'wb') as f: + f.write(audio_data) + + print(f"Removed corrupted ID3 tag from: {os.path.basename(path)}") + return True + except Exception as e: + print(f"Failed to remove bad tag for {path}: {e}") + return False + +async def identify_shazam(shazam_client, path): + try: + out = await shazam_client.recognize(path) if len(out['matches']) < 1: - print("Shazam could not find a match") - continue - data = Serialize.track(out['track']) + return None - # Extract data from Shazam response - tags = {} - tags['title'] = out['track']['title'] - tags['artist'] = out['track']['subtitle'] - tags['genre'] = out['track']['genres']['primary'] + track = out['track'] + data = Serialize.track(track) - cover_art = out['track']['images']['coverarthq'].replace("400x400", "1000x1000") + tags = { + 'title': track['title'], + 'artist': track['subtitle'], + 'genre': track['genres']['primary'] if 'primary' in track.get('genres', {}) else None, + 'album': None, + 'year': None, + 'cover_art': track['images']['coverarthq'].replace("400x400", "1000x1000") if 'images' in track and 'coverarthq' in track['images'] else None + } for section in data.sections: if section.type == "SONG": @@ -45,40 +74,166 @@ async def main(): if md.title == "Released": tags['year'] = md.text - print("Shazam: " + tags['artist'] + " - " + tags['title'] + " (" + tags['album'] + ")") - - # Reset current tags - id3 = eyed3.load(path) - if id3.tag: - id3.tag.clear() - id3.tag.save() - else: - id3.initTag() - id3.tag.save() - - # Save new tags - id3 = eyed3.load(path) - id3.tag.album = tags['album'] - id3.tag.artist = tags['artist'] - id3.tag.album_artist = tags['artist'] - id3.tag.genre = tags['genre'] - id3.tag.title = tags['title'] - id3.tag.year = tags['year'] - - # Download art and (temporary) save locally - local_art = os.path.join(MUSIC_FOLDER, "tmp_file" + cover_art[-4:]) - urllib.request.urlretrieve(cover_art, local_art) - - # Save art to MP3 file - id3_front_cover_id = eyed3.utils.art.TO_ID3_ART_TYPES['FRONT_COVER'][0] - id3_cover = eyed3.plugins.art.ArtFile(local_art) - id3_cover.id3_art_type = id3_front_cover_id - id3.tag.images.set(id3_cover.id3_art_type, id3_cover.image_data, id3_cover.mime_type) - - # Save the new tags - id3.tag.save(version=(2, 3, 0)) - - print("Saved new tags") - -loop = asyncio.get_event_loop() -loop.run_until_complete(main()) + return tags + except Exception as e: + print(f"Shazam error: {e}") + return None + +def identify_acoustid(api_key, path): + try: + results = acoustid.match(api_key, path) + for score, recording_id, title, artist in results: + if score > 0.8: + return { + 'title': title, + 'artist': artist, + 'album': None, # AcoustID simple lookup might not give album + 'year': None, + 'genre': None, + 'cover_art': None, + 'musicbrainz_id': recording_id + } + except Exception as e: + print(f"AcoustID error: {e}") + return None + +def fetch_musicbrainz_metadata(artist, title): + print(f"Fetching MusicBrainz metadata for: {artist} - {title}") + try: + # Search for recordings + result = musicbrainzngs.search_recordings(artist=artist, recording=title, limit=1) + if result['recording-count'] == 0: + return None + + recording = result['recording-list'][0] + + tags = { + 'title': recording['title'], + 'artist': recording['artist-credit-phrase'], + 'album': None, + 'year': None, + 'genre': None + } + + if 'release-list' in recording and len(recording['release-list']) > 0: + release = recording['release-list'][0] + tags['album'] = release['title'] + if 'date' in release: + tags['year'] = release['date'].split('-')[0] + + if 'tag-list' in recording: + # Get the most popular tag as genre + tags['genre'] = max(recording['tag-list'], key=lambda t: int(t['count']))['name'] + + return tags + except Exception as e: + print(f"MusicBrainz error: {e}") + return None + +async def main(music_folder, acoustid_key=None): + shazam = Shazam() + + if not os.path.exists(music_folder): + print(f"Error: Path '{music_folder}' not found.") + return + + print(f"Scanning recursively: {music_folder}") + + for root, dirs, files in os.walk(music_folder): + for file in files: + path = os.path.join(root, file) + + if not os.path.isfile(path): + continue + if not file.lower().endswith(".mp3"): + continue + + print(f"Processing: {file}") + + tags = None + source = None + + # 1. Try Shazam + tags = await identify_shazam(shazam, path) + if tags: + source = "Shazam" + + # 2. Try AcoustID if Shazam failed and key provided + if not tags and acoustid_key: + print("Shazam failed, trying AcoustID...") + tags = identify_acoustid(acoustid_key, path) + if tags: + source = "AcoustID" + + if not tags: + print(f"Could not identify: {file}") + continue + + # 3. Enhance with MusicBrainz (Optional but recommended) + # If we have basic info, validade/enhance with MB + mb_tags = fetch_musicbrainz_metadata(tags['artist'], tags['title']) + if mb_tags: + print("Enhanced with MusicBrainz data") + # Merge tags, preferring MB for text data, but keeping Shazam/AcoustID cover art/genre if missing + tags['album'] = mb_tags.get('album') or tags.get('album') + tags['year'] = mb_tags.get('year') or tags.get('year') + # Use MB artist/title for standardization + tags['artist'] = mb_tags['artist'] + tags['title'] = mb_tags['title'] + if not tags.get('genre'): + tags['genre'] = mb_tags.get('genre') + + print(f"Identified ({source}): {tags['artist']} - {tags['title']} ({tags.get('album', 'Unknown Album')})") + + # Apply tags + try: + try: + id3 = eyed3.load(path) + except Exception: + if remove_corrupted_tag(path): + id3 = eyed3.load(path) + else: + raise + + if not id3: + print(f"Could not load ID3 tags for {file}") + continue + + if not id3.tag: + id3.initTag() + + id3.tag.album = tags.get('album') + id3.tag.artist = tags['artist'] + id3.tag.album_artist = tags['artist'] + id3.tag.genre = tags.get('genre') + id3.tag.title = tags['title'] + id3.tag.year = tags.get('year') + + # Handle Cover Art + if tags.get('cover_art'): + local_art = os.path.join(root, "tmp_file" + tags['cover_art'][-4:]) + try: + urllib.request.urlretrieve(tags['cover_art'], local_art) + id3_front_cover_id = eyed3.utils.art.TO_ID3_ART_TYPES['FRONT_COVER'][0] + id3_cover = eyed3.plugins.art.ArtFile(local_art) + id3_cover.id3_art_type = id3_front_cover_id + id3.tag.images.set(id3_cover.id3_art_type, id3_cover.image_data, id3_cover.mime_type) + except Exception as e: + print(f"Error downloading cover art: {e}") + finally: + if os.path.exists(local_art): + os.remove(local_art) + + id3.tag.save(version=(2, 3, 0)) + print("Saved new tags") + + except Exception as e: + print(f"Error tagging {file}: {e}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Auto ID3 Tagger via Shazam, AcoustID & MusicBrainz") + parser.add_argument("folder", help="path to the music folder") + parser.add_argument("--acoustid-key", help="Optional API key for AcoustID recognition", required=False) + args = parser.parse_args() + + asyncio.run(main(args.folder, args.acoustid_key)) diff --git a/requirements.txt b/requirements.txt index b842aa5..13b0e0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ eyeD3 python-magic-bin -shazamio \ No newline at end of file +shazamio +musicbrainzngs +pyacoustid \ No newline at end of file