Last active
August 16, 2025 08:05
-
-
Save lambdan/04e267266908bdc932912d69b9aac429 to your computer and use it in GitHub Desktop.
Spotify CSV (Exportify) to m3u by matching ISRC
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
import os, json, csv, sys, subprocess | |
from tqdm import tqdm | |
# Export from Spotify using exportify.app, then run this on it when you have the songs! | |
metaFile = "meta.json" | |
audioExts = [".mp3", ".flac", ".ogg", ".opus", ".wav", ".m4a", ".aac"] | |
musicDir = "/Volumes/Media/Music/Organized/" # CHANGE THIS! | |
playlistBasepath = "../" # CHANGE THIS relative to musicDir from the playlists perspective | |
if len(sys.argv) < 2: | |
print("Usage: python csv-to-m3u.py <csv_file>") | |
print("Example: python csv-to-m3u.py my_playlist.csv") | |
sys.exit(1) | |
csvFile = sys.argv[1] | |
if not os.path.exists(csvFile): | |
print(f"CSV file {csvFile} does not exist.") | |
sys.exit(1) | |
def getMeta(filepath): | |
try: | |
fullpath = os.path.abspath(filepath) | |
if not os.path.exists(fullpath): | |
print(f"File {fullpath} does not exist.") | |
sys.exit(1) | |
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", fullpath] | |
called = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8") | |
jsonData = json.loads(called.stdout) | |
isrc = jsonData.get("format", {}).get("tags", {}).get("ISRC", "") | |
return { | |
"ext": os.path.splitext(filepath)[1].lower().strip().removeprefix("."), | |
"isrc": isrc.strip() if isrc else None, | |
"ffprobe": jsonData | |
} | |
except Exception as e: | |
print(f"Error extracting ISRC from {filepath}: {e}") | |
return None | |
def save(data, filename): | |
with open(filename, 'w', encoding='utf-8') as f: | |
json.dump(data, f, indent=4, ensure_ascii=False) | |
print("Saved") | |
seen = set() | |
metaDict = {} | |
if os.path.isfile(metaFile): | |
with open(metaFile, 'r', encoding='utf-8') as f: | |
metaDict = json.load(f) | |
seen = set(metaDict.keys()) | |
def buildDict(): | |
for root, dirs, files in tqdm(os.walk(musicDir)): | |
needToSave = False | |
for file in tqdm(files): | |
if any(file.lower().endswith(ext) for ext in audioExts): | |
filepath = os.path.join(root, file) | |
if filepath in seen: | |
continue | |
metaDict[filepath] = getMeta(filepath) | |
seen.add(filepath) | |
needToSave = True | |
if needToSave: | |
save(metaDict, metaFile) | |
# go through folder and build ISRC dict | |
buildDict() | |
# check for missing files | |
needToSave = False | |
for filepath in tqdm(list(metaDict.keys())): | |
if not os.path.exists(filepath): | |
print(f"File {filepath} does not exist, removing from metaDict") | |
del metaDict[filepath] | |
seen.remove(filepath) | |
needToSave = True | |
if needToSave: | |
save(metaDict, metaFile) | |
final = {} | |
missing = [] | |
with open(csvFile, 'r', encoding='utf-8') as f: | |
reader = csv.DictReader(f) # type: ignore | |
data = [row for row in reader] | |
print(len(data), "rows found in CSV") | |
for d in data: | |
#for k,v in d.items(): | |
#print(f"{k}={v}") | |
title = d.get("Track Name", "") | |
artist = d.get("Artist Name(s)", "") | |
album = d.get("Album Name", "") | |
album_artist = d.get("Album Artist Name(s)", "") | |
isrc = d.get("ISRC", "") | |
key = isrc if isrc else f"{artist} - {title} - {album}" | |
found = False | |
for filepath in metaDict: | |
fp_isrc = metaDict[filepath].get("isrc", "") | |
# TODO Match by title/artist if ISRC is not available | |
if fp_isrc and isrc in fp_isrc: | |
found = True | |
if key in final and final[key]["ext"] == "flac": | |
continue # prefer FLAC if available | |
final[key] = { | |
"filepath": filepath, | |
"title": title, | |
"artist": artist, | |
"album": album, | |
"album_artist": album_artist, | |
"isrc": fp_isrc, | |
"ext": metaDict[filepath].get("ext", "") | |
} | |
if not found: | |
print("Missing", isrc, title, artist, album, album_artist) | |
missing.append({ | |
"title": title, | |
"artist": artist, | |
"album": album, | |
"album_artist": album_artist, | |
"isrc": isrc | |
}) | |
print("Got ", len(final), "tracks with ISRCs") | |
out = "" | |
for key, track in final.items(): | |
basepath = track["filepath"].replace(musicDir, "../") | |
out += f"#EXTINF:-1,{track['artist']} - {track['title']}\n" | |
out += f"{basepath}\n" | |
for m in missing: | |
out += f"# Missing: {m['artist']} - {m['title']}\n" | |
#print(out) | |
outputFile = os.path.splitext(csvFile)[0] + ".m3u" | |
with open(outputFile, 'w', encoding='utf-8') as f: | |
f.write(out) | |
print(f"Output written to {outputFile}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment