Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
## 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.
285 changes: 220 additions & 65 deletions id_song.py
Original file line number Diff line number Diff line change
@@ -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":
Expand All @@ -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))
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
eyeD3
python-magic-bin
shazamio
shazamio
musicbrainzngs
pyacoustid