Last active
December 12, 2024 05:58
-
-
Save erighetto/e00a61d1157c6af779cb5ff66948e9d9 to your computer and use it in GitHub Desktop.
a Python script that embeds album cover images directly into each MP3 file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# When transferring an iTunes library to a new Mac, one common issue is that album artwork often fails to display correctly. | |
# This can happen because iTunes stores cover art separately from the actual MP3 files, so artwork may not carry over smoothly during migration. | |
# To solve this, a Python script can be used to embed album cover images directly into each MP3 file's metadata. | |
# By embedding the artwork, each song will retain its cover art no matter where it’s moved or which music player is used. | |
# This approach ensures a more seamless experience, making the iTunes library look complete and visually organized on the new device. | |
# This script simplifies the embedding process by automating image retrieval and integration for each MP3 file, | |
# saving users from the hassle of manually adding artwork. | |
import os | |
import xml.etree.ElementTree as ET | |
import sqlite3 | |
import ffmpeg | |
from urllib.request import unquote | |
from html import unescape | |
import tempfile | |
import shutil | |
# Path to iTunes library XML file | |
ITUNES_LIBRARY_XML = os.path.expanduser("~/Library/CloudStorage/OneDrive-Personale/MusicLibrary/MusicLibrayItems.xml") | |
# Artwork folder | |
ARTWORKS_PATH = os.path.expanduser("~/Library/Containers/com.apple.AMPArtworkAgent/Data/Documents/artwork") | |
# SQLite database path | |
ARTWORK_DB_PATH = os.path.expanduser("~/Library/Containers/com.apple.AMPArtworkAgent/Data/Documents/artworkd.sqlite") | |
def get_all_artwork_ids(): | |
try: | |
conn = sqlite3.connect(ARTWORK_DB_PATH) | |
cursor = conn.cursor() | |
# Fetch all distinct artwork hashes | |
cursor.execute("SELECT DISTINCT ZHASHSTRING FROM ZIMAGEINFO") | |
artworks = cursor.fetchall() | |
conn.close() | |
return [artwork[0] for artwork in artworks] | |
except sqlite3.Error as e: | |
print(f"SQLite database error: {e}") | |
return [] | |
def get_persistent_ids_for_artwork(artwork_id): | |
try: | |
conn = sqlite3.connect(ARTWORK_DB_PATH) | |
cursor = conn.cursor() | |
# Fetch all persistent IDs associated with a given artwork | |
cursor.execute(""" | |
SELECT dbi.ZPERSISTENTID | |
FROM ZDATABASEITEMINFO dbi | |
JOIN ZSOURCEINFO si ON si.Z_PK = dbi.ZSOURCEINFO | |
JOIN ZIMAGEINFO ii ON ii.Z_PK = si.ZIMAGEINFO | |
WHERE ii.ZHASHSTRING = ? | |
""", (artwork_id,)) | |
persistent_ids = cursor.fetchall() | |
conn.close() | |
return [persistent_id[0] for persistent_id in persistent_ids] | |
except sqlite3.Error as e: | |
print(f"SQLite database error: {e}") | |
return [] | |
def find_related_tracks(persistent_id): | |
""" | |
Find all tracks that belong to the same album as the given persistent ID. | |
This ensures that all tracks from the same album share the same artwork. | |
""" | |
try: | |
tree = ET.parse(ITUNES_LIBRARY_XML) | |
root = tree.getroot() | |
# Convert persistent ID to hexadecimal | |
hex_persistent_id = decimal_to_hex(persistent_id) | |
target_album = None | |
target_artist = None | |
target_year = None | |
# First, find the track with the given persistent ID | |
for track in root.findall(".//dict/dict"): | |
track_persistent_id = None | |
album = None | |
artist = None | |
year = None | |
for i in range(0, len(track), 2): | |
key = track[i].text | |
if key == "Persistent ID": | |
track_persistent_id = track[i + 1].text | |
elif key == "Album": | |
album = track[i + 1].text | |
elif key == "Artist": | |
artist = track[i + 1].text | |
elif key == "Year": | |
year = track[i + 1].text | |
if track_persistent_id and track_persistent_id == hex_persistent_id: | |
target_album = album | |
target_artist = artist | |
target_year = year | |
break | |
if not target_album or not target_artist: | |
print(f"No related tracks found for persistent ID {persistent_id}.") | |
return [] | |
# Now, find all tracks with the same album, artist, and year | |
related_tracks = [] | |
for track in root.findall(".//dict/dict"): | |
album = None | |
artist = None | |
year = None | |
location = None | |
for i in range(0, len(track), 2): | |
key = track[i].text | |
if key == "Album": | |
album = track[i + 1].text | |
elif key == "Artist": | |
artist = track[i + 1].text | |
elif key == "Year": | |
year = track[i + 1].text | |
elif key == "Location": | |
location = track[i + 1].text | |
if album == target_album and artist == target_artist and year == target_year: | |
if location: | |
mp3_path = location.replace("file://", "") | |
related_tracks.append(unescape(unquote(mp3_path))) | |
return related_tracks | |
except Exception as e: | |
print(f"Error finding related tracks for persistent ID {persistent_id}: {e}") | |
return [] | |
def apply_artwork_to_mp3(mp3_file, artwork_file): | |
try: | |
# Create a temporary file | |
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_file: | |
temp_mp3_path = temp_file.name | |
# Add artwork and save output to temporary file | |
ffmpeg.input(mp3_file).output( | |
temp_mp3_path, **{'c:v': 'copy', 'c:a': 'copy', 'metadata:s:v': f'file={artwork_file}'} | |
).run(overwrite_output=True) | |
# Replace original file with the temporary file | |
shutil.move(temp_mp3_path, mp3_file) | |
print(f"Successfully added artwork to {mp3_file}") | |
except Exception as e: | |
print(f"Error adding artwork to {mp3_file}: {e}") | |
# Remove temporary file in case of error | |
if os.path.exists(temp_mp3_path): | |
os.remove(temp_mp3_path) | |
def decimal_to_hex(decimal_id): | |
# Convert integer to unsigned 16-digit hexadecimal string | |
return format(decimal_id & (2**64 - 1), '016X') | |
def has_artwork(file_path): | |
# Check if the file has artwork | |
try: | |
probe = ffmpeg.probe(file_path) | |
return any(stream['codec_type'] == 'video' for stream in probe['streams']) | |
except ffmpeg.Error as e: | |
print(f"Error probing {file_path}: {e}") | |
return False | |
def get_artwork_file(artwork_id): | |
# Find the artwork file path | |
for dirpath, _, filenames in os.walk(ARTWORKS_PATH): | |
for file in filenames: | |
if artwork_id in file: | |
return os.path.join(dirpath, file) | |
return None | |
def is_mp3(file_path): | |
return file_path.lower().endswith('.mp3') | |
# Execute the algorithm for each artwork | |
for artwork_id in get_all_artwork_ids(): | |
# Retrieve all persistent IDs associated with this artwork | |
persistent_ids = get_persistent_ids_for_artwork(artwork_id) | |
artwork_file = get_artwork_file(artwork_id) | |
if not artwork_file: | |
print(f'Artwork not found for artwork ID {artwork_id}.') | |
continue | |
# For each persistent ID, get the MP3 file path and apply the artwork | |
for persistent_id in persistent_ids: | |
related_tracks = find_related_tracks(persistent_id) | |
artwork_file = get_artwork_file(artwork_id) | |
if not artwork_file: | |
print(f"Artwork not found for persistent ID {persistent_id} - {decimal_to_hex(persistent_id)}.") | |
continue | |
for mp3_file in related_tracks: | |
if os.path.exists(mp3_file) and is_mp3(mp3_file): | |
if has_artwork(mp3_file): | |
print(f"{mp3_file} already has artwork. Skipping.") | |
continue | |
apply_artwork_to_mp3(mp3_file, artwork_file) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment