Skip to content

Instantly share code, notes, and snippets.

@JoeBlakeB
Last active January 8, 2026 13:21
Show Gist options
  • Select an option

  • Save JoeBlakeB/bd6910aa53bfb8180b36a731a19c7ef2 to your computer and use it in GitHub Desktop.

Select an option

Save JoeBlakeB/bd6910aa53bfb8180b36a731a19c7ef2 to your computer and use it in GitHub Desktop.
Manage playlists for a music library with a CSV file.
#!/usr/bin/env python
"""
musicPlaylistGenerator.py
Copyright (C) 2025 Joe Baker (JoeBlakeB)
This program is free software under the GPLv3 license.
Generate and Update a CSV file based on the contents of a folder containing
music files, then generate M3U playlist files containing those songs.
Example Usage, to generate the CSV file:
$ musicPlaylistGenerator.py
Will generate the file: music.csv
Artist, Album, Title, Playlist 1, Playlist 2
Sia This is Acting Cheap Thrills x x
Clean Bandit New Eyes Rather Be x
Chappel Roan The Subway The Subway x x
Years & Years Communion Shine x
Then generate M3U files using:
$ musicPlaylistGenerator.py
Which will output two files:
Playlist 1.m3u:
Sia - Cheap Thrills.mp3
Clean Bandit - Rather Be.mp3
Chappel Roan - The Subway.mp3
Playlist 2.m3u:
Sia - Cheap Thrills.mp3
Chappel Roan - The Subway.mp3
Years & Years - Shine.mp3
Notes:
- The file structure and format can be in any format, but the music files
must contain embedded metadata for each file to be distinct.
- File paths and their metadata can both be changed, meaning you can restructure
your directory however you want, and change metadata, but only one of those two
at the same time. If you change the filename and metadata at the same time,
the track will get lost and its playlists cleared. Output playlists will contain
the full path of the file, relative to the specified directory.
- The CSV file is updated if it already exists, removing songs that have
been deleted, and adding new ones that were added.
- Running the script updates the CSV, then if the CSV existed before,
generates new playlist files, overwriting the old ones.
- The intended workflow is:
- Mange music files in your music directory
- Run this script to generate the CSV
- Edit the CSV to manage your playlists
- Run this script again to generate the M3Us
- The status column has two values other than blank:
- New: the file has been added to the CSV in the previous run
- Deleted: the file has been lose in the previous run, and will be deleted from the CSV in the next run
Command line args (this section might be moved to only show up when doing --help but not be in this block)
--musicDirectory -d (or the -1 argument?)
--csvPath -c (including filename, same as folder name in the folder if not specified)
--playlistOutputDirectory -p
--skipCsvUpdate
--skipPlaylistCreation
$ musicPlaylistGenerator.py
musicDirectory: .
csvPath: music.csv
playlists: playlist.m3u
$ musicPlaylistGenerator.py Music
musicDirectory: Music
csvPath: Music/music.csv
playlists: Music/playlist.m3u
$ musicPlaylistGenerator.py MinecraftOST
musicDirectory: MinecraftOST
csvPath: MinecraftOST/MinecraftOST.csv
playlists: MinecraftOST/playlist.m3u
"""
import csv
import argparse
import os
from pathlib import Path
from dataclasses import dataclass
from mutagen import File as MutagenFile
MUSIC_FILETYPES = ["mp3", "flac", "m4a", "aac", "ogg", "opus", "wma"]
def parseArgs(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse and normalize command line arguments."""
parser = argparse.ArgumentParser(
description="Generate or update playlist CSVs and M3U files.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"musicDirectory",
nargs="?",
default=Path("."),
type=Path,
help="Root directory containing music files",
)
parser.add_argument(
"-c",
"--csvPath",
type=Path,
default=None,
help="CSV path (defaults to <musicDirectory>/music.csv)",
)
parser.add_argument(
"-p",
"--playlistOutputDirectory",
type=Path,
default=None,
help="Directory to write playlist files (defaults to musicDirectory)",
)
parser.add_argument(
"--skipCsvUpdate",
action="store_true",
help="Skip scanning music files and updating the CSV",
)
parser.add_argument(
"--skipPlaylistCreation",
action="store_true",
help="Skip generating M3U playlist files",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug output",
)
parser.add_argument(
"--justifyCSV",
action="store_true",
default=True,
help="Align CSV columns for readability (default: True)",
)
parser.add_argument(
"--no-justifyCSV",
dest="justifyCSV",
action="store_false",
help="Disable CSV column alignment",
)
parser.add_argument(
"--changesAtTop",
action="store_true",
help="Place new tracks at the top of the CSV",
)
args = parser.parse_args(argv)
music_dir = args.musicDirectory
default_csv_name = "music.csv" if music_dir.name in {"", "."} else f"{music_dir.name}.csv"
args.csvPath = args.csvPath or music_dir / default_csv_name
args.playlistOutputDirectory = (
args.playlistOutputDirectory or args.musicDirectory
)
return args
@dataclass
class Track:
"""Represents a music track with metadata."""
path: Path
title: str
artist: str
album: str | None
playlists: dict[str, str] = None
new: bool = True
deleted: bool = False
@dataclass
class Playlist:
"""Represents a playlist containing multiple tracks."""
name: str
tracks: list[Track]
def normalize(s: str) -> str:
return ''.join(c.lower() for c in s if c.isalnum())
def equalsIgnoreSpecials(str1: str, str2: str) -> bool:
"""Compare two strings ignoring case and special characters."""
return normalize(str1) == normalize(str2)
def removefiletype(filename: str) -> str:
"""Remove the file extension from a filename."""
return os.path.splitext(filename)[0]
def extractMetadata(filepath: Path) -> Track:
"""Extract title, artist, and album metadata from a music file to create a Track object"""
try:
audio = MutagenFile(filepath, easy=True)
if audio is not None:
title = None
artist = None
album = None
if "title" in audio and audio["title"]:
title = audio["title"][0]
if "artist" in audio and audio["artist"]:
artist = audio["artist"][0]
elif "albumartist" in audio and audio["albumartist"]:
artist = audio["albumartist"][0]
if "album" in audio and audio["album"]:
album = audio["album"][0]
if title and artist:
return Track(path=filepath, title=title, artist=artist, album=album)
except Exception as e:
print(f"Error: Could not read metadata from {filepath}: {e}")
print(f"Warning: Could not read metadata from {filepath}")
if " - " in filepath.stem:
parts = filepath.stem.split(" - ", 1)
title, artist = parts[1], parts[0]
else:
title, artist = filepath.stem, "Unknown Artist"
return Track(path=filepath, title=title, artist=artist, album=None)
def getAllTracks(musicDirectory: Path) -> list[Track]:
"""Recursively get all music files in the specified directory."""
allTracks = []
for filetype in MUSIC_FILETYPES:
for filepath in musicDirectory.rglob(f"*.{filetype}"):
allTracks.append(extractMetadata(filepath))
return allTracks
def getPlaylistsFromOldCSV(csvPath: Path, allTracks: list[Track]) -> list[Playlist]:
"""Read existing CSV file and extract playlists."""
playlists = []
with csvPath.open("r", encoding="utf-8") as csvFile:
header = csvFile.readline().strip().split(",")
filenameColumn = header[0].strip() == "FileID"
statusColumn = header[3+filenameColumn].strip() == "Status"
playlistsStartAt = 3 + filenameColumn + statusColumn
playlistNames = header[playlistsStartAt:]
for name in playlistNames:
playlists.append(Playlist(name=name.strip(), tracks=[]))
for line in csvFile:
columns = line.strip().split(",")
filename = columns[0].strip() if filenameColumn else ""
artist = columns[0+filenameColumn].strip()
album = columns[1+filenameColumn].strip()
title = columns[2+filenameColumn].strip()
wasDeletedLastTime = statusColumn and columns[3+filenameColumn].strip().lower() == "deleted"
trackPlaylists = columns[playlistsStartAt:]
matchingTracks = [
track for track in allTracks
if ((equalsIgnoreSpecials(track.title, title) and
equalsIgnoreSpecials(track.artist, artist) and
(equalsIgnoreSpecials(track.album, album) or album == ""))
or (equalsIgnoreSpecials(filename, removefiletype(track.path)) if filenameColumn else False))
]
if not wasDeletedLastTime:
for track in matchingTracks:
track.new = False
if not matchingTracks:
if wasDeletedLastTime:
continue
deletedTrack = Track(path=Path(""), title=title, artist=artist, album=album, new=False, deleted=True)
allTracks.append(deletedTrack)
matchingTracks.append(deletedTrack)
for i, inPlaylist in enumerate(trackPlaylists):
if inPlaylist.strip():
if matchingTracks:
playlists[i].tracks.append(matchingTracks[0])
for track in matchingTracks:
if track.playlists is None:
track.playlists = {}
track.playlists[playlists[i].name] = inPlaylist.strip()
return playlists
def createNewCSV(csvPath: Path, playlists: list[Playlist], allTracks: list[Track], justify: bool = True) -> None:
"""Create or update the CSV file with the current tracks."""
playlistNames = [pl.name for pl in playlists]
rows = []
header = ["FileID", "Artist", "Album", "Title", "Status"] + playlistNames
rows.append(header)
for track in allTracks:
row = [
normalize(removefiletype(removefiletype(track.path))),
track.artist.replace(",", " "),
(track.album or "").replace(",", " "),
track.title.replace(",", " "),
"New" if track.new else "Deleted" if track.deleted else ""
]
for plName in playlistNames:
row.append(track.playlists.get(plName, "") if track.playlists else "")
rows.append(row)
if justify:
colWidths = [max(len(row[i]) if i < len(row) else 0 for row in rows)
for i in range(len(header))]
colWidths[-1] = 0
with csvPath.open("w", encoding="utf-8") as csvFile:
for row in rows:
if justify:
padded = [cell.ljust(colWidths[i]) for i, cell in enumerate(row)]
csvFile.write(",".join(padded) + "\n")
else:
csvFile.write(",".join(row) + "\n")
def createPlaylists(outputDirectory: Path, playlists: list[Playlist]) -> None:
"""Create M3U playlist files from the playlists."""
outputDirectory.mkdir(parents=True, exist_ok=True)
for playlist in playlists:
playlistPath = outputDirectory / f"{playlist.name}.m3u"
with playlistPath.open("w", encoding="utf-8") as m3uFile:
for track in playlist.tracks:
relativePath = track.path.relative_to(args.musicDirectory)
m3uFile.write(f"{relativePath.as_posix()}\n")
if __name__ == "__main__":
args = parseArgs()
debug = print if args.debug else (lambda *a, **k: None)
debug(f"musicDirectory: {args.musicDirectory}")
debug(f"csvPath: {args.csvPath}")
debug(f"playlistOutputDirectory: {args.playlistOutputDirectory}")
debug(f"skipCsvUpdate: {args.skipCsvUpdate}")
debug(f"skipPlaylistCreation: {args.skipPlaylistCreation}")
allTracks = getAllTracks(args.musicDirectory)
print(f"Found {len(allTracks)} tracks.")
commonPath = os.path.commonpath([track.path.parent for track in allTracks]) if allTracks else None
if commonPath and Path(commonPath).resolve() != args.musicDirectory.resolve():
print(f"Warning: All tracks are located under '{commonPath}'. Did you forget to specify the music directory?")
playlists = getPlaylistsFromOldCSV(args.csvPath, allTracks) if args.csvPath.exists() else []
print(f"New Tracks: {sum(1 for track in allTracks if track.new)}")
print(f"Deleted Tracks: {sum(1 for track in allTracks if track.deleted)}")
if args.changesAtTop:
allTracks.sort(key=lambda t: (not t.deleted, not t.new, t.artist.lower(), t.album.lower() if t.album else "", t.title.lower()))
else:
allTracks.sort(key=lambda t: (t.artist.lower(), t.album.lower() if t.album else "", t.title.lower()))
if not args.skipCsvUpdate:
createNewCSV(args.csvPath, playlists, allTracks, justify=args.justifyCSV)
if not args.skipPlaylistCreation:
createPlaylists(args.playlistOutputDirectory, playlists)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment